From d228e338d92902879f9021cb5f574f9aed8b8911 Mon Sep 17 00:00:00 2001 From: Eduardo <6845999+eduardosmaniotto@users.noreply.github.com> Date: Wed, 29 Apr 2026 02:33:19 -0300 Subject: [PATCH 1/6] feature: create search configuration and replace Blazored.Typeahead with custom Typeahead --- src/Dapr/GameServer.Host/_Imports.razor | 3 +- .../Items/IncreasableItemOption.cs | 20 +- src/Directory.Packages.props | 1 - .../EntityFramework/TypedContext.cs | 4 +- src/PlugIns/PlugInConfiguration.cs | 20 +- .../Layout/ConfigurationSearch.razor | 45 ++ .../Layout/ConfigurationSearch.razor.cs | 204 +++++++++ .../Layout/ConfigurationSearch.razor.css | 60 +++ .../Components/Layout/MainLayout.razor | 42 +- .../Components/Layout/MainLayout.razor.css | 80 ++-- .../Components/Layout/NavMenu.razor.cs | 25 +- .../MUnique.OpenMU.Web.AdminPanel.csproj | 3 +- src/Web/AdminPanel/Pages/EditAccount.razor.cs | 4 +- src/Web/AdminPanel/Pages/EditBase.cs | 20 +- src/Web/AdminPanel/Pages/EditConfig.cs | 12 +- src/Web/AdminPanel/Pages/Plugins.razor | 34 +- src/Web/AdminPanel/Pages/Plugins.razor.cs | 60 +++ .../Services/ConfigurationSearchEntry.cs | 20 + .../Services/ConfigurationSearchIndexCache.cs | 415 ++++++++++++++++++ src/Web/AdminPanel/Startup.cs | 4 +- .../AdminPanel/WebApplicationExtensions.cs | 3 +- src/Web/AdminPanel/_Imports.razor | 3 +- src/Web/Shared/Components/Form/AutoFields.cs | 70 ++- src/Web/Shared/Components/Form/AutoForm.razor | 70 ++- .../Shared/Components/Form/AutoForm.razor.cs | 83 ++++ .../Components/Form/FlagsEnumField.razor | 22 +- .../Shared/Components/Form/LookupField.razor | 18 +- .../Shared/Components/Form/Typeahead.razor | 124 ++++++ .../Shared/Components/Form/Typeahead.razor.cs | 356 +++++++++++++++ .../Components/Form/Typeahead.razor.css | 137 ++++++ src/Web/Shared/Exports.cs | 4 +- .../Shared/MUnique.OpenMU.Web.Shared.csproj | 3 +- .../Shared/Properties/Resources.Designer.cs | 9 + src/Web/Shared/Properties/Resources.resx | 5 +- .../Services/EnumerableLookupController.cs | 12 +- .../PersistentObjectsLookupController.cs | 14 +- src/Web/Shared/Services/PlugInController.cs | 39 +- src/Web/Shared/_Imports.razor | 3 +- 38 files changed, 1847 insertions(+), 204 deletions(-) create mode 100644 src/Web/AdminPanel/Components/Layout/ConfigurationSearch.razor create mode 100644 src/Web/AdminPanel/Components/Layout/ConfigurationSearch.razor.cs create mode 100644 src/Web/AdminPanel/Components/Layout/ConfigurationSearch.razor.css create mode 100644 src/Web/AdminPanel/Pages/Plugins.razor.cs create mode 100644 src/Web/AdminPanel/Services/ConfigurationSearchEntry.cs create mode 100644 src/Web/AdminPanel/Services/ConfigurationSearchIndexCache.cs create mode 100644 src/Web/Shared/Components/Form/AutoForm.razor.cs create mode 100644 src/Web/Shared/Components/Form/Typeahead.razor create mode 100644 src/Web/Shared/Components/Form/Typeahead.razor.cs create mode 100644 src/Web/Shared/Components/Form/Typeahead.razor.css diff --git a/src/Dapr/GameServer.Host/_Imports.razor b/src/Dapr/GameServer.Host/_Imports.razor index 33ad06f18..2f2d4aafa 100644 --- a/src/Dapr/GameServer.Host/_Imports.razor +++ b/src/Dapr/GameServer.Host/_Imports.razor @@ -1,4 +1,4 @@ -@using System.Net.Http +@using System.Net.Http @using System.Net.Http.Json @using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Components.Authorization @@ -15,7 +15,6 @@ @using Blazored.Modal.Services @using Blazored.Toast @using Blazored.Toast.Services -@using Blazored.Typeahead @using BlazorInputFile diff --git a/src/DataModel/Configuration/Items/IncreasableItemOption.cs b/src/DataModel/Configuration/Items/IncreasableItemOption.cs index 9d846d6ed..dc3a39c9d 100644 --- a/src/DataModel/Configuration/Items/IncreasableItemOption.cs +++ b/src/DataModel/Configuration/Items/IncreasableItemOption.cs @@ -1,9 +1,10 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // namespace MUnique.OpenMU.DataModel.Configuration.Items; +using System.Linq; using MUnique.OpenMU.Annotations; /// @@ -54,4 +55,21 @@ public partial class IncreasableItemOption : ItemOption /// [MemberOfAggregate] public virtual ICollection LevelDependentOptions { get; protected set; } = null!; + + /// + public override string ToString() + { + if (this.PowerUpDefinition != null) + { + return base.ToString(); + } + + var firstLevelOption = this.LevelDependentOptions?.OrderBy(l => l.Level).FirstOrDefault(); + if (firstLevelOption?.PowerUpDefinition != null) + { + return $"{this.OptionType}: {firstLevelOption.PowerUpDefinition} ({this.Number})"; + } + + return base.ToString(); + } } \ No newline at end of file diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 36db961fa..730c47460 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -8,7 +8,6 @@ - diff --git a/src/Persistence/EntityFramework/TypedContext.cs b/src/Persistence/EntityFramework/TypedContext.cs index 44cd859ab..9cb50fc9b 100644 --- a/src/Persistence/EntityFramework/TypedContext.cs +++ b/src/Persistence/EntityFramework/TypedContext.cs @@ -1,4 +1,4 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // @@ -41,7 +41,7 @@ public TypedContext(Type editType) } /// - public IEntityType RootType => this._rootType ??= this.Model.GetEntityTypes().First(t => t.ClrType.BaseType == this.EditType); + public IEntityType RootType => this._rootType ??= this.Model.GetEntityTypes().First(t => t.ClrType == this.EditType || t.ClrType.BaseType == this.EditType); /// /// Gets the type which is edited with this context. diff --git a/src/PlugIns/PlugInConfiguration.cs b/src/PlugIns/PlugInConfiguration.cs index 0ef54a89f..6a13ea967 100644 --- a/src/PlugIns/PlugInConfiguration.cs +++ b/src/PlugIns/PlugInConfiguration.cs @@ -1,4 +1,4 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // @@ -81,7 +81,7 @@ public string Name get { var plugInType = AppDomain.CurrentDomain.GetAssemblies() - .SelectMany(assembly => assembly.DefinedTypes) + .SelectMany(GetTypesSafely) .FirstOrDefault(t => t.GUID == this.TypeId); var plugInAttribute = plugInType?.GetCustomAttribute(inherit: false); @@ -103,4 +103,20 @@ protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName { this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } + + private static IEnumerable GetTypesSafely(Assembly assembly) + { + try + { + return assembly.DefinedTypes; + } + catch (ReflectionTypeLoadException ex) + { + return ex.Types.Where(t => t != null).Select(t => t!.GetTypeInfo()); + } + catch + { + return Enumerable.Empty(); + } + } } \ No newline at end of file diff --git a/src/Web/AdminPanel/Components/Layout/ConfigurationSearch.razor b/src/Web/AdminPanel/Components/Layout/ConfigurationSearch.razor new file mode 100644 index 000000000..624f2748e --- /dev/null +++ b/src/Web/AdminPanel/Components/Layout/ConfigurationSearch.razor @@ -0,0 +1,45 @@ +@using MUnique.OpenMU.Web.AdminPanel.Properties + + diff --git a/src/Web/AdminPanel/Components/Layout/ConfigurationSearch.razor.cs b/src/Web/AdminPanel/Components/Layout/ConfigurationSearch.razor.cs new file mode 100644 index 000000000..6b30ecb20 --- /dev/null +++ b/src/Web/AdminPanel/Components/Layout/ConfigurationSearch.razor.cs @@ -0,0 +1,204 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.AdminPanel.Components.Layout; + +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using MUnique.OpenMU.Web.AdminPanel.Services; +using MUnique.OpenMU.Web.Shared.Components; +using MUnique.OpenMU.Web.Shared.Services; + +/// +/// Header search for configuration properties. +/// +public partial class ConfigurationSearch : IDisposable +{ + private const int MinimumSearchLength = 2; + private const int MaximumResults = 15; + + private readonly Debouncer _searchDebouncer = new(200); + private readonly List _searchResults = new(); + + private bool _isLoading; + private string _searchText = string.Empty; + private IReadOnlyList _searchEntries = Array.Empty(); + + [Inject] + private ConfigurationSearchIndexCache SearchIndexCache { get; set; } = null!; + + [Inject] + private NavigationManager NavigationManager { get; set; } = null!; + + [Inject] + private NavigationHistory NavigationHistory { get; set; } = null!; + + [Inject] + private SetupService SetupService { get; set; } = null!; + + /// + public void Dispose() + { + this.SetupService.DatabaseInitialized -= this.OnDatabaseInitializedAsync; + this._searchDebouncer.Dispose(); + } + + /// + protected override Task OnInitializedAsync() + { + this.SetupService.DatabaseInitialized += this.OnDatabaseInitializedAsync; + if (this.SearchIndexCache.IsLoaded) + { + this._searchEntries = this.SearchIndexCache.Entries; + } + + return base.OnInitializedAsync(); + } + + private static int CalculateScore(ConfigurationSearchEntry entry, string normalizedQuery, IReadOnlyList queryParts) + { + if (queryParts.Count == 0 || !queryParts.All(part => entry.NormalizedHaystack.Contains(part, StringComparison.Ordinal))) + { + return int.MaxValue; + } + + var score = 100; + if (entry.NormalizedCaption.StartsWith(normalizedQuery, StringComparison.Ordinal)) + { + score -= 60; + } + else if (entry.NormalizedCaption.Contains(normalizedQuery, StringComparison.Ordinal)) + { + score -= 45; + } + + if (entry.NormalizedHaystack.StartsWith(normalizedQuery, StringComparison.Ordinal)) + { + score -= 20; + } + + score += entry.Path.Length / 64; + return score; + } + + private static string Normalize(string value) + { + return string.Join( + ' ', + value.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + .ToUpperInvariant(); + } + + private async Task OnSearchFocusAsync(FocusEventArgs _) + { + await this.EnsureIndexLoadedAsync().ConfigureAwait(true); + this.UpdateSearchResults(); + } + + private Task OnSearchInputAsync() + { + + _ = this._searchDebouncer.DebounceAsync(async token => + { + if (this._searchEntries.Count == 0) + { + await this.EnsureIndexLoadedAsync().ConfigureAwait(false); + } + + if (!token.IsCancellationRequested) + { + await this.InvokeAsync(() => + { + this.UpdateSearchResults(); + this.StateHasChanged(); + }).ConfigureAwait(false); + } + }); + + return Task.CompletedTask; + } + + private async Task OnSearchBlurAsync(FocusEventArgs _) + { + await Task.Delay(100).ConfigureAwait(true); + this._searchResults.Clear(); + } + + private Task OnSearchKeyDownAsync(KeyboardEventArgs args) + { + if (string.Equals(args.Key, "Escape", StringComparison.Ordinal)) + { + this._searchText = string.Empty; + this._searchResults.Clear(); + } + else if (string.Equals(args.Key, "Enter", StringComparison.Ordinal) + && this._searchResults.FirstOrDefault() is { } firstResult) + { + this.NavigateToResult(firstResult); + } + + return Task.CompletedTask; + } + + private async ValueTask OnDatabaseInitializedAsync() + { + this.SearchIndexCache.Invalidate(); + this._searchEntries = Array.Empty(); + this._searchResults.Clear(); + await this.InvokeAsync(this.StateHasChanged).ConfigureAwait(false); + } + + private void UpdateSearchResults() + { + this._searchResults.Clear(); + if (this._searchEntries.Count == 0) + { + return; + } + + var normalizedQuery = Normalize(this._searchText); + if (normalizedQuery.Length < MinimumSearchLength) + { + return; + } + + var queryParts = normalizedQuery.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var results = this._searchEntries + .Select(entry => (Entry: entry, Score: CalculateScore(entry, normalizedQuery, queryParts))) + .Where(result => result.Score < int.MaxValue) + .OrderBy(result => result.Score) + .ThenBy(result => result.Entry.Path, StringComparer.Ordinal) + .Take(MaximumResults) + .Select(result => result.Entry); + + this._searchResults.AddRange(results); + } + + private void NavigateToResult(ConfigurationSearchEntry entry) + { + this._searchText = string.Empty; + this._searchResults.Clear(); + this.NavigationHistory.Clear(); + this.NavigationManager.NavigateTo(entry.Url); + } + + private async Task EnsureIndexLoadedAsync() + { + if (this._searchEntries.Count > 0 || this._isLoading) + { + return; + } + + this._isLoading = true; + try + { + await this.SearchIndexCache.EnsureLoadedAsync().ConfigureAwait(true); + this._searchEntries = this.SearchIndexCache.Entries; + } + finally + { + this._isLoading = false; + } + } +} diff --git a/src/Web/AdminPanel/Components/Layout/ConfigurationSearch.razor.css b/src/Web/AdminPanel/Components/Layout/ConfigurationSearch.razor.css new file mode 100644 index 000000000..4330a2f3e --- /dev/null +++ b/src/Web/AdminPanel/Components/Layout/ConfigurationSearch.razor.css @@ -0,0 +1,60 @@ +.configuration-search { + position: relative; + width: min(100%, 48rem); + min-width: 16rem; + z-index: 90; +} + +.configuration-search__results { + position: absolute; + top: calc(100% + 0.15rem); + left: 0; + width: 100%; + max-height: min(68vh, 28rem); + z-index: 2200; + overflow-y: auto; + margin: 0; + padding: 0.25rem 0; +} + +.configuration-search__result { + width: 100%; + display: flex; + flex-direction: column; + align-items: flex-start; + text-align: left; + gap: 0.14rem; + padding: 0.5rem 1rem; + cursor: pointer; + white-space: normal; +} + +.configuration-search__result-caption { + font-weight: 600; + color: inherit; +} + +.configuration-search__result-path { + font-size: 0.78rem; + color: #5f6368; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + width: 100%; +} + +.configuration-search__result:hover .configuration-search__result-path, +.configuration-search__result:focus .configuration-search__result-path { + color: rgba(0, 0, 0, 0.7); +} + +@media (max-width: 640.98px) { + .configuration-search { + width: 100%; + min-width: 0; + } + + .configuration-search__results { + max-height: min(56vh, 22rem); + } +} \ No newline at end of file diff --git a/src/Web/AdminPanel/Components/Layout/MainLayout.razor b/src/Web/AdminPanel/Components/Layout/MainLayout.razor index fc9cc9378..70b8fd2e7 100644 --- a/src/Web/AdminPanel/Components/Layout/MainLayout.razor +++ b/src/Web/AdminPanel/Components/Layout/MainLayout.razor @@ -1,22 +1,38 @@ -@using MUnique.OpenMU.Web.AdminPanel.Properties +@using MUnique.OpenMU.Web.AdminPanel.Properties @inherits LayoutComponentBase
- - -
-
- - @Resources.About + -
- @Body -
-
+
+ + + + + + +
+ @Body +
+
diff --git a/src/Web/AdminPanel/Components/Layout/MainLayout.razor.css b/src/Web/AdminPanel/Components/Layout/MainLayout.razor.css index 38d1f2598..c23df24e8 100644 --- a/src/Web/AdminPanel/Components/Layout/MainLayout.razor.css +++ b/src/Web/AdminPanel/Components/Layout/MainLayout.razor.css @@ -12,37 +12,47 @@ main { background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); } -.top-row { - background-color: #f7f7f7; - border-bottom: 1px solid #d6d5d5; - justify-content: flex-end; - height: 3.5rem; +.main-header { + min-height: 4rem; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); +} + +.header-search { + max-width: 48rem; +} + +.breadcrumb-bar { + min-height: 2.5rem; +} + +.breadcrumb-bar ::deep .breadcrumb-nav { display: flex; align-items: center; + min-width: 0; + flex: 1; } - .top-row ::deep a, .top-row ::deep .btn-link { - white-space: nowrap; - margin-left: 1.5rem; - text-decoration: none; - } - - .top-row ::deep a:hover, .top-row ::deep .btn-link:hover { - text-decoration: underline; - } +.breadcrumb-bar ::deep .breadcrumb { + margin-bottom: 0; + padding: 0.25rem 0; + background-color: transparent; +} - .top-row ::deep a:first-child { - overflow: hidden; - text-overflow: ellipsis; - } +.breadcrumb-bar ::deep .btn-group { + margin-right: 1rem; +} @media (max-width: 640.98px) { - .top-row { - justify-content: space-between; + .header-content { + flex-direction: column; + align-items: stretch !important; + gap: 0.5rem; + padding: 0.5rem 0; } - .top-row ::deep a, .top-row ::deep .btn-link { - margin-left: 0; + .header-actions { + margin-left: 0 !important; + text-align: right; } } @@ -58,21 +68,9 @@ main { top: 0; } - .top-row { + .main-header { position: sticky; top: 0; - z-index: 1; - } - - .top-row.auth ::deep a:first-child { - flex: 1; - text-align: right; - width: 0; - } - - .top-row, article { - padding-left: 2rem !important; - padding-right: 1.5rem !important; } } @@ -90,9 +88,9 @@ main { z-index: 1000; } - #blazor-error-ui .dismiss { - cursor: pointer; - position: absolute; - right: 0.75rem; - top: 0.5rem; - } +#blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; +} \ No newline at end of file diff --git a/src/Web/AdminPanel/Components/Layout/NavMenu.razor.cs b/src/Web/AdminPanel/Components/Layout/NavMenu.razor.cs index 490356957..e8034554b 100644 --- a/src/Web/AdminPanel/Components/Layout/NavMenu.razor.cs +++ b/src/Web/AdminPanel/Components/Layout/NavMenu.razor.cs @@ -40,6 +40,9 @@ public partial class NavMenu : IDisposable [Inject] private NavigationHistory NavigationHistory { get; set; } = null!; + [Inject] + private ConfigurationSearchIndexCache ConfigurationSearchIndexCache { get; set; } = null!; + private Guid? GameConfigurationId { get; set; } /// @@ -65,6 +68,7 @@ protected override async Task OnInitializedAsync() _ = Task.Run(async () => { await this.LoadGameConfigurationAsync().ConfigureAwait(false); + await this.WarmupConfigurationSearchAsync().ConfigureAwait(false); await this.CheckForUpdatesAsync().ConfigureAwait(false); }); } @@ -79,7 +83,9 @@ private async ValueTask OnDatabaseInitializedAsync() { // We have to reload, because the old links are not correct anymore. this.GameConfigurationId = null; + this.ConfigurationSearchIndexCache.Invalidate(); await this.LoadGameConfigurationAsync().ConfigureAwait(false); + await this.WarmupConfigurationSearchAsync().ConfigureAwait(false); await this.CheckForUpdatesAsync().ConfigureAwait(false); } @@ -144,4 +150,21 @@ private void ToggleNavMenu() { this._collapseNavMenu = !this._collapseNavMenu; } -} \ No newline at end of file + + private async Task WarmupConfigurationSearchAsync() + { + if (this.GameConfigurationId is null) + { + return; + } + + try + { + await this.ConfigurationSearchIndexCache.EnsureLoadedAsync().ConfigureAwait(false); + } + catch + { + // Search warmup is optional. + } + } +} diff --git a/src/Web/AdminPanel/MUnique.OpenMU.Web.AdminPanel.csproj b/src/Web/AdminPanel/MUnique.OpenMU.Web.AdminPanel.csproj index fcad3b535..ed4940112 100644 --- a/src/Web/AdminPanel/MUnique.OpenMU.Web.AdminPanel.csproj +++ b/src/Web/AdminPanel/MUnique.OpenMU.Web.AdminPanel.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -27,7 +27,6 @@ - diff --git a/src/Web/AdminPanel/Pages/EditAccount.razor.cs b/src/Web/AdminPanel/Pages/EditAccount.razor.cs index f95e70a87..f1dbe7baa 100644 --- a/src/Web/AdminPanel/Pages/EditAccount.razor.cs +++ b/src/Web/AdminPanel/Pages/EditAccount.razor.cs @@ -1,4 +1,4 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // @@ -9,6 +9,7 @@ namespace MUnique.OpenMU.Web.AdminPanel.Pages; using Microsoft.AspNetCore.Components.Rendering; using MUnique.OpenMU.DataModel.Entities; using MUnique.OpenMU.Persistence; +using MUnique.OpenMU.Web.AdminPanel.Properties; using MUnique.OpenMU.Web.Shared.Components.Form; using MUnique.OpenMU.Web.Shared.Components.ItemEdit; @@ -55,6 +56,7 @@ protected override void AddFormToRenderTree(RenderTreeBuilder builder, ref int c builder.OpenComponent(++currentSequence, typeof(AutoForm<>).MakeGenericType(this.Type!)); builder.AddAttribute(++currentSequence, nameof(AutoForm.Model), this.Model); builder.AddAttribute(++currentSequence, nameof(AutoForm.OnValidSubmit), EventCallback.Factory.Create(this, this.SaveChangesAsync)); + builder.AddAttribute(++currentSequence, nameof(AutoForm.OnRefresh), EventCallback.Factory.Create(this, this.RefreshAsync)); builder.CloseComponent(); } } diff --git a/src/Web/AdminPanel/Pages/EditBase.cs b/src/Web/AdminPanel/Pages/EditBase.cs index e1130dae4..4cfc916e5 100644 --- a/src/Web/AdminPanel/Pages/EditBase.cs +++ b/src/Web/AdminPanel/Pages/EditBase.cs @@ -18,8 +18,8 @@ namespace MUnique.OpenMU.Web.AdminPanel.Pages; using MUnique.OpenMU.Persistence; using MUnique.OpenMU.Web.AdminPanel.Properties; using MUnique.OpenMU.Web.Shared; -using MUnique.OpenMU.Web.Shared.Services; using MUnique.OpenMU.Web.Shared.Components; +using MUnique.OpenMU.Web.Shared.Services; /// /// Abstract common base class for an edit page. @@ -187,7 +187,6 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) var editorsMarkup = this.GetEditorsMarkup(); builder.AddMarkupContent(10, $"

{Resources.Edit} {this.Type!.GetTypeCaption()}

{downloadMarkup}{editorsMarkup}\r\n"); - this.RenderRefreshButton(builder); builder.OpenComponent>(11); builder.AddAttribute(12, nameof(CascadingValue.Value), this._persistenceContext); @@ -274,6 +273,12 @@ protected async Task SaveChangesAsync() ///
protected async Task RefreshAsync() { + var isConfirmed = await this.JavaScript.InvokeAsync("window.confirm", Resources.UnsavedChangesQuestion).ConfigureAwait(true); + if (!isConfirmed) + { + return; + } + await this.EditDataSource.ForceDiscardChangesAsync().ConfigureAwait(true); this._loadingState = DataLoadingState.LoadingStarted; var cts = new CancellationTokenSource(); @@ -282,17 +287,6 @@ protected async Task RefreshAsync() this.StateHasChanged(); } - private void RenderRefreshButton(RenderTreeBuilder builder) - { - builder.OpenElement(100, "p"); - builder.OpenElement(101, "button"); - builder.AddAttribute(102, "class", "btn btn-secondary"); - builder.AddAttribute(103, "onclick", EventCallback.Factory.Create(this, this.RefreshAsync)); - builder.AddContent(104, Resources.Refresh); - builder.CloseElement(); - builder.CloseElement(); - } - /// /// Gets the optional editors markup for the current type. /// diff --git a/src/Web/AdminPanel/Pages/EditConfig.cs b/src/Web/AdminPanel/Pages/EditConfig.cs index 4f97cdbd8..7a46b0de3 100644 --- a/src/Web/AdminPanel/Pages/EditConfig.cs +++ b/src/Web/AdminPanel/Pages/EditConfig.cs @@ -1,4 +1,4 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // @@ -27,6 +27,12 @@ public sealed class EditConfig : EditBase { typeof(GameMapDefinition), new List<(string, string)> { (Resources.MapEditor, "/map-editor/{0}") } }, }; + /// + /// Gets or sets the optional search term to pre-filter fields. + /// + [SupplyParameterFromQuery(Name = "search")] + public string? SearchTerm { get; set; } + /// protected override void AddFormToRenderTree(RenderTreeBuilder builder, ref int currentSequence) { @@ -44,7 +50,9 @@ protected override void AddFormToRenderTree(RenderTreeBuilder builder, ref int c builder.OpenComponent(++currentSequence, typeof(AutoForm<>).MakeGenericType(this.Type!)); builder.AddAttribute(++currentSequence, nameof(AutoForm.Model), this.Model); builder.AddAttribute(++currentSequence, nameof(AutoForm.HideCollections), hideCollections); + builder.AddAttribute(++currentSequence, nameof(AutoForm.SearchTerm), this.SearchTerm); builder.AddAttribute(++currentSequence, nameof(AutoForm.OnValidSubmit), EventCallback.Factory.Create(this, this.SaveChangesAsync)); + builder.AddAttribute(++currentSequence, nameof(AutoForm.OnRefresh), EventCallback.Factory.Create(this, this.RefreshAsync)); builder.CloseComponent(); } } @@ -67,4 +75,4 @@ protected override void AddFormToRenderTree(RenderTreeBuilder builder, ref int c return stringBuilder?.ToString(); } -} \ No newline at end of file +} diff --git a/src/Web/AdminPanel/Pages/Plugins.razor b/src/Web/AdminPanel/Pages/Plugins.razor index 6ee327b70..47d31b84b 100644 --- a/src/Web/AdminPanel/Pages/Plugins.razor +++ b/src/Web/AdminPanel/Pages/Plugins.razor @@ -1,9 +1,7 @@ -@page "/plugins" +@page "/plugins" @using MUnique.OpenMU.Web.AdminPanel.Properties @using MUnique.OpenMU.Web.Shared.Models -@inject PlugInController _plugInController; - @Resources.Plugins

@Resources.Plugins

@@ -17,32 +15,32 @@ @Resources.Action - - - + + + @item.PlugInPointName - + - + @if (item.IsActive) { - + } else { - + } @if (item.ConfigurationType is { }) { - + } @@ -53,16 +51,4 @@ - - -@code { - - private void OnPlugInPointSelected(ChangeEventArgs args) - { - if (args.Value is string guidString - && Guid.TryParse(guidString, out var result)) - { - this._plugInController.PointFilter = result; - } - } -} \ No newline at end of file + \ No newline at end of file diff --git a/src/Web/AdminPanel/Pages/Plugins.razor.cs b/src/Web/AdminPanel/Pages/Plugins.razor.cs new file mode 100644 index 000000000..641594a23 --- /dev/null +++ b/src/Web/AdminPanel/Pages/Plugins.razor.cs @@ -0,0 +1,60 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.AdminPanel.Pages; + +using Microsoft.AspNetCore.Components; +using MUnique.OpenMU.Web.Shared.Services; + +/// +/// Code-behind for the page. +/// +public partial class Plugins +{ + /// + /// Gets or sets the plug-in identifier. + /// + [SupplyParameterFromQuery(Name = "id")] + public string? PlugInId { get; set; } + + [Inject] + private PlugInController PlugInController { get; set; } = null!; + + /// + protected override async Task OnParametersSetAsync() + { + await base.OnParametersSetAsync().ConfigureAwait(true); + + if (Guid.TryParse(this.PlugInId, out var id)) + { + var plugin = await this.PlugInController.GetByIdAsync(id).ConfigureAwait(true); + + if (plugin is { }) + { + this.PlugInController.NameFilter = plugin.PlugInName ?? string.Empty; + this.PlugInController.TypeFilter = string.Empty; + this.PlugInController.PointFilter = Guid.Empty; + + if (plugin.ConfigurationType is { }) + { + _ = Task.Run(async () => + { + await Task.Delay(100).ConfigureAwait(false); + await this.InvokeAsync(() => this.PlugInController.ShowPlugInConfigAsync(plugin)).ConfigureAwait(false); + }); + } + } + + this.PlugInId = null; + } + } + + private void OnPlugInPointSelected(ChangeEventArgs args) + { + if (args.Value is string guidString && Guid.TryParse(guidString, out var result)) + { + this.PlugInController.PointFilter = result; + } + } +} diff --git a/src/Web/AdminPanel/Services/ConfigurationSearchEntry.cs b/src/Web/AdminPanel/Services/ConfigurationSearchEntry.cs new file mode 100644 index 000000000..cb55c36bd --- /dev/null +++ b/src/Web/AdminPanel/Services/ConfigurationSearchEntry.cs @@ -0,0 +1,20 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.AdminPanel.Services; + +/// +/// A precomputed configuration-search entry. +/// +/// The display caption. +/// The full path in the configuration graph. +/// The target edit URL. +/// The normalized searchable text. +/// The normalized caption. +public sealed record ConfigurationSearchEntry( + string Caption, + string Path, + string Url, + string NormalizedHaystack, + string NormalizedCaption); diff --git a/src/Web/AdminPanel/Services/ConfigurationSearchIndexCache.cs b/src/Web/AdminPanel/Services/ConfigurationSearchIndexCache.cs new file mode 100644 index 000000000..9a2ec9e8c --- /dev/null +++ b/src/Web/AdminPanel/Services/ConfigurationSearchIndexCache.cs @@ -0,0 +1,415 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.AdminPanel.Services; + +using System.Collections; +using System.Collections.Concurrent; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Reflection; +using System.Threading; +using Microsoft.Extensions.Logging; +using MUnique.OpenMU.DataModel; +using MUnique.OpenMU.DataModel.Composition; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.Persistence; + +/// +/// Caches configuration search entries for fast header search navigation. +/// +public class ConfigurationSearchIndexCache +{ + private const int MaximumTraversalDepth = 20; + + private static readonly Type[] SupportedEditableTypes = GameConfigurationHelper.Enumerables.Keys.OrderByDescending(GetInheritanceDepth).ToArray(); + private static readonly ConcurrentDictionary EditableTypeByRuntimeType = new(); + private static readonly ConcurrentDictionary> SearchablePropertiesCache = new(); + + private readonly IMigratableDatabaseContextProvider _persistenceContextProvider; + private readonly IDataSource _configDataSource; + private readonly ILogger _logger; + private readonly SemaphoreSlim _loadingLock = new(1, 1); + + private bool _isLoaded; + private IReadOnlyList _entries = Array.Empty(); + + /// + /// Initializes a new instance of the class. + /// + /// The persistence context provider. + /// The configuration data source. + /// The logger. + public ConfigurationSearchIndexCache( + IMigratableDatabaseContextProvider persistenceContextProvider, + IDataSource configDataSource, + ILogger logger) + { + this._persistenceContextProvider = persistenceContextProvider; + this._configDataSource = configDataSource; + this._logger = logger; + } + + /// + /// Gets a value indicating whether the cache was loaded at least once. + /// + public bool IsLoaded => this._isLoaded; + + /// + /// Gets the cached entries. + /// + public IReadOnlyList Entries => this._entries; + + /// + /// Ensures the cache is populated. + /// + /// The cancellation token. + /// A task. + public async Task EnsureLoadedAsync(CancellationToken cancellationToken = default) + { + if (this._isLoaded) + { + return; + } + + await this._loadingLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (this._isLoaded) + { + return; + } + + this._entries = await this.LoadEntriesAsync(cancellationToken).ConfigureAwait(false); + this._isLoaded = true; + } + finally + { + this._loadingLock.Release(); + } + } + + /// + /// Invalidates the cache. + /// + public void Invalidate() + { + this._entries = Array.Empty(); + this._isLoaded = false; + } + + private static IReadOnlyList BuildSearchIndex(GameConfiguration gameConfiguration, Guid gameConfigurationId) + { + var fullTypeName = typeof(GameConfiguration).FullName; + if (fullTypeName is null) + { + return Array.Empty(); + } + + var result = new List(2048); + var uniqueKeys = new HashSet(StringComparer.Ordinal); + var rootPath = typeof(GameConfiguration).GetTypeCaption(); + var rootUrl = $"/edit-config/{fullTypeName}/{gameConfigurationId}"; + var visited = new HashSet(ReferenceEqualityComparer.Instance); + + AddSearchEntry( + result, + uniqueKeys, + rootPath, + rootPath, + rootUrl, + typeof(GameConfiguration).Name); + TraverseObject(gameConfiguration, rootUrl, rootPath, visited, 0, result, uniqueKeys); + return result; + } + + private static void TraverseObject( + object current, + string currentUrl, + string currentPath, + HashSet visited, + int depth, + List result, + HashSet uniqueKeys) + { + if (depth > MaximumTraversalDepth || !visited.Add(current)) + { + return; + } + + var type = current.GetType(); + foreach (var property in GetSearchableProperties(type)) + { + var propertyCaption = GetPropertyCaption(type, property); + var propertyPath = $"{currentPath} > {propertyCaption}"; + var propertyUrl = AppendSearchParameter(currentUrl, property.Name); + + AddSearchEntry( + result, + uniqueKeys, + propertyCaption, + propertyPath, + propertyUrl, + property.Name, + property.PropertyType.Name, + type.Name); + + var propertyValue = GetPropertyValue(property, current); + if (propertyValue is null || propertyValue is byte[]) + { + continue; + } + + var valueType = propertyValue.GetType(); + if (IsSimpleType(valueType)) + { + continue; + } + + if (propertyValue is IEnumerable enumerable and not string) + { + TraverseCollection(enumerable, propertyPath, propertyUrl, visited, depth + 1, result, uniqueKeys); + continue; + } + + if (valueType.IsValueType) + { + continue; + } + + var childUrl = GetEditUrlForObject(propertyValue) ?? propertyUrl; + var childTypeCaption = valueType.GetTypeCaption(); + AddSearchEntry( + result, + uniqueKeys, + childTypeCaption, + propertyPath, + childUrl, + property.Name, + valueType.Name); + TraverseObject(propertyValue, childUrl, propertyPath, visited, depth + 1, result, uniqueKeys); + } + } + + private static void TraverseCollection( + IEnumerable collection, + string propertyPath, + string propertyUrl, + HashSet visited, + int depth, + List result, + HashSet uniqueKeys) + { + var index = 0; + foreach (var item in collection.Cast()) + { + if (item is null) + { + index++; + continue; + } + + var itemType = item.GetType(); + var itemCaption = GetItemCaption(item, index); + var itemPath = $"{propertyPath} > {itemCaption}"; + var itemUrl = GetEditUrlForObject(item) ?? propertyUrl; + + AddSearchEntry( + result, + uniqueKeys, + itemCaption, + itemPath, + itemUrl, + itemType.Name); + + if (!IsSimpleType(itemType) && !itemType.IsValueType) + { + TraverseObject(item, itemUrl, itemPath, visited, depth + 1, result, uniqueKeys); + } + + index++; + } + } + + private static void AddSearchEntry( + List result, + HashSet uniqueKeys, + string caption, + string path, + string url, + params string[] aliases) + { + var key = $"{path}|{url}"; + if (!uniqueKeys.Add(key)) + { + return; + } + + var haystack = string.Join(' ', aliases.Prepend(caption).Append(path)); + result.Add(new ConfigurationSearchEntry(caption, path, url, Normalize(haystack), Normalize(caption))); + } + + private static string GetPropertyCaption(Type type, PropertyInfo propertyInfo) + { + return propertyInfo.GetCustomAttribute()?.GetName() + ?? type.GetPropertyCaption(propertyInfo.Name); + } + + private static IReadOnlyList GetSearchableProperties(Type type) + { + return SearchablePropertiesCache.GetOrAdd(type, t => + t.GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.FlattenHierarchy) + .Where(p => p.GetCustomAttribute() is null) + .Where(p => p.GetCustomAttribute()?.Browsable ?? true) + .Where(p => !p.Name.StartsWith("Raw", StringComparison.Ordinal)) + .Where(p => !p.Name.StartsWith("Joined", StringComparison.Ordinal)) + .Where(p => !p.GetIndexParameters().Any()) + .ToList()); + } + + private static string GetItemCaption(object item, int index) + { + var typeCaption = item.GetType().GetTypeCaption(); + var name = item.GetName(); + if (!string.IsNullOrWhiteSpace(name)) + { + return $"{typeCaption}: {name}"; + } + + var id = item.GetId(); + return id == Guid.Empty + ? $"{typeCaption} #{index + 1}" + : $"{typeCaption}: {id}"; + } + + private static object? GetPropertyValue(PropertyInfo propertyInfo, object instance) + { + try + { + return propertyInfo.GetValue(instance); + } + catch + { + return null; + } + } + + private static bool IsSimpleType(Type type) + { + type = Nullable.GetUnderlyingType(type) ?? type; + return type.IsPrimitive + || type.IsEnum + || type.IsValueType + || type == typeof(string) + || type == typeof(Guid) + || type == typeof(Uri) + || type == typeof(LocalizedString); + } + + private static string? GetEditUrlForObject(object item) + { + if (item is MUnique.OpenMU.PlugIns.PlugInConfiguration plugInConfiguration) + { + return $"/plugins?id={plugInConfiguration.GetId()}"; + } + + var runtimeType = item.GetType(); + var editableType = ResolveEditableType(runtimeType); + var fullTypeName = editableType?.FullName; + if (fullTypeName is null) + { + return null; + } + + var id = item.GetId(); + return id != Guid.Empty ? $"/edit-config/{fullTypeName}/{id}" : null; + } + + private static Type? ResolveEditableType(Type runtimeType) + { + return EditableTypeByRuntimeType.GetOrAdd(runtimeType, type => + { + var editableType = SupportedEditableTypes.FirstOrDefault(candidate => candidate.IsAssignableFrom(type)); + if (editableType is not null) + { + return editableType; + } + + return EnumerateTypeAndBaseTypes(type) + .FirstOrDefault(t => + !t.Assembly.IsDynamic + && t.GetProperty(nameof(IIdentifiable.Id), BindingFlags.Instance | BindingFlags.Public) is not null); + }); + } + + private static IEnumerable EnumerateTypeAndBaseTypes(Type type) + { + for (var current = type; current is not null && current != typeof(object); current = current.BaseType) + { + yield return current; + } + } + + private static int GetInheritanceDepth(Type type) + { + var depth = 0; + for (var current = type; current is not null && current != typeof(object); current = current.BaseType) + { + depth++; + } + + return depth; + } + + private static string AppendSearchParameter(string url, string searchTerm) + { + if (string.IsNullOrWhiteSpace(searchTerm)) + { + return url; + } + + var separator = url.Contains('?', StringComparison.Ordinal) ? "&" : "?"; + return $"{url}{separator}search={Uri.EscapeDataString(searchTerm)}"; + } + + private static string Normalize(string value) + { + return string.Join( + ' ', + value.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + .ToUpperInvariant(); + } + + private async Task> LoadEntriesAsync(CancellationToken cancellationToken) + { + try + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(10)); + var token = cts.Token; + + if (!await this._persistenceContextProvider.CanConnectToDatabaseAsync(token).ConfigureAwait(false) + || !await this._persistenceContextProvider.DatabaseExistsAsync(token).ConfigureAwait(false)) + { + return Array.Empty(); + } + + using var context = this._persistenceContextProvider.CreateNewConfigurationContext(); + var gameConfigurationId = await context.GetDefaultGameConfigurationIdAsync(token).ConfigureAwait(false); + if (gameConfigurationId is not { } id || id == Guid.Empty) + { + return Array.Empty(); + } + + var gameConfiguration = await this._configDataSource.GetOwnerAsync(id, token).ConfigureAwait(false); + return BuildSearchIndex(gameConfiguration, id); + } + catch (Exception ex) + { + this._logger.LogError(ex, "Could not load the configuration search index."); + return Array.Empty(); + } + } +} diff --git a/src/Web/AdminPanel/Startup.cs b/src/Web/AdminPanel/Startup.cs index 76550f17e..8cbf89973 100644 --- a/src/Web/AdminPanel/Startup.cs +++ b/src/Web/AdminPanel/Startup.cs @@ -15,6 +15,7 @@ namespace MUnique.OpenMU.Web.AdminPanel; using Microsoft.Extensions.Hosting; using MUnique.OpenMU.DataModel.Entities; using MUnique.OpenMU.Web.AdminPanel.Components; +using MUnique.OpenMU.Web.AdminPanel.Services; using MUnique.OpenMU.Web.Shared; using MUnique.OpenMU.Web.Shared.Models; using MUnique.OpenMU.Web.Shared.Services; @@ -69,6 +70,7 @@ public void ConfigureServices(IServiceCollection services) services.AddScoped>(serviceProvider => serviceProvider.GetService()!); services.AddSingleton(); + services.AddSingleton(); services.AddScoped(); } @@ -109,4 +111,4 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) endpoints.MapControllers(); }); } -} \ No newline at end of file +} diff --git a/src/Web/AdminPanel/WebApplicationExtensions.cs b/src/Web/AdminPanel/WebApplicationExtensions.cs index 62880174b..de937864e 100644 --- a/src/Web/AdminPanel/WebApplicationExtensions.cs +++ b/src/Web/AdminPanel/WebApplicationExtensions.cs @@ -73,6 +73,7 @@ public static WebApplicationBuilder AddAdminPanel(this WebApplicationBuilder bui services.AddSingleton, GameConfigurationDataSource>(); services.AddSingleton, AccountDataSource>(); + services.AddSingleton(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -129,4 +130,4 @@ public static WebApplication ConfigureAdminPanel(this WebApplication app) return app; } -} \ No newline at end of file +} diff --git a/src/Web/AdminPanel/_Imports.razor b/src/Web/AdminPanel/_Imports.razor index e5c9e6cd3..cb068c1e4 100644 --- a/src/Web/AdminPanel/_Imports.razor +++ b/src/Web/AdminPanel/_Imports.razor @@ -1,4 +1,4 @@ -@using System.Net.Http +@using System.Net.Http @using System.Net.Http.Json @using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Components.Authorization @@ -15,7 +15,6 @@ @using Blazored.Modal.Services @using Blazored.Toast @using Blazored.Toast.Services -@using Blazored.Typeahead @using BlazorInputFile diff --git a/src/Web/Shared/Components/Form/AutoFields.cs b/src/Web/Shared/Components/Form/AutoFields.cs index 56c1497ed..890601c4c 100644 --- a/src/Web/Shared/Components/Form/AutoFields.cs +++ b/src/Web/Shared/Components/Form/AutoFields.cs @@ -4,6 +4,7 @@ namespace MUnique.OpenMU.Web.Shared.Components.Form; +using System.Collections.Concurrent; using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Reflection; @@ -22,6 +23,7 @@ namespace MUnique.OpenMU.Web.Shared.Components.Form; public class AutoFields : ComponentBase { private static readonly IList Builders = new List(); + private static readonly ConcurrentDictionary> CachedProperties = new(); /// /// Initializes static members of the class. @@ -70,6 +72,12 @@ static AutoFields() [Parameter] public bool HideCollections { get; set; } + /// + /// Gets or sets the search term to filter properties by their name or display name. + /// + [Parameter] + public string? SearchTerm { get; set; } + /// /// Gets the properties which should be shown in this component. /// @@ -86,23 +94,22 @@ protected virtual IEnumerable Properties try { - return this.Context.Model.GetType() - .GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.FlattenHierarchy) - .Where(p => p.GetCustomAttribute() is null) - .Where(p => p.GetCustomAttribute()?.Browsable ?? true) - .Where(p => !p.Name.StartsWith("Raw", StringComparison.InvariantCulture)) - .Where(p => !p.Name.StartsWith("Joined", StringComparison.InvariantCulture)) - .Where(p => !p.GetIndexParameters().Any()) - .Where(p => !this.HideCollections || !p.PropertyType.IsGenericType) - .OrderBy(p => p.GetCustomAttribute()?.GetOrder()) - .ThenByDescending(p => p.PropertyType == typeof(string)) - .ThenByDescending(p => p.PropertyType.IsValueType) - .ThenByDescending(p => !p.PropertyType.IsGenericType) + var modelType = this.Context.Model.GetType(); + var properties = CachedProperties.GetOrAdd(modelType, CreatePropertyMetadata); + + return properties + .Where(p => !this.HideCollections || !p.IsGenericType) + .Where(this.IsMatch) + .OrderBy(p => p.DisplayAttribute?.GetOrder()) + .ThenByDescending(p => p.IsString) + .ThenByDescending(p => p.IsValueType) + .ThenByDescending(p => !p.IsGenericType) + .Select(p => p.Property) .ToList(); } catch (Exception ex) { - this.Logger.LogError(ex, $"Error during determining properties of type {this.Context.Model.GetType()}: {ex.Message}{Environment.NewLine}{ex.StackTrace}"); + this.Logger.LogError(ex, "Error during determining properties of type {ModelType}: {Message}", this.Context.Model.GetType(), ex.Message); } return Enumerable.Empty(); @@ -143,4 +150,39 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) } } } -} \ No newline at end of file + + private static IReadOnlyList CreatePropertyMetadata(Type type) + { + return type.GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.FlattenHierarchy) + .Where(p => p.GetCustomAttribute() is null) + .Where(p => p.GetCustomAttribute()?.Browsable ?? true) + .Where(p => !p.Name.StartsWith("Raw", StringComparison.Ordinal)) + .Where(p => !p.Name.StartsWith("Joined", StringComparison.Ordinal)) + .Where(p => !p.GetIndexParameters().Any()) + .Select(p => new PropertyMetadata( + p, + p.GetCustomAttribute(), + p.PropertyType.IsGenericType, + p.PropertyType == typeof(string), + p.PropertyType.IsValueType)) + .ToList(); + } + + private bool IsMatch(PropertyMetadata metadata) + { + if (string.IsNullOrWhiteSpace(this.SearchTerm)) + { + return true; + } + + return metadata.Property.Name.Contains(this.SearchTerm, StringComparison.OrdinalIgnoreCase) + || (metadata.DisplayAttribute?.GetName()?.Contains(this.SearchTerm, StringComparison.OrdinalIgnoreCase) ?? false); + } + + private sealed record PropertyMetadata( + PropertyInfo Property, + DisplayAttribute? DisplayAttribute, + bool IsGenericType, + bool IsString, + bool IsValueType); +} diff --git a/src/Web/Shared/Components/Form/AutoForm.razor b/src/Web/Shared/Components/Form/AutoForm.razor index a383dc288..dd9d49667 100644 --- a/src/Web/Shared/Components/Form/AutoForm.razor +++ b/src/Web/Shared/Components/Form/AutoForm.razor @@ -1,42 +1,32 @@ -@typeparam T +@typeparam T - - - +
+ @if (ShowSearch) + { +
+ +
+ } - -
- - @if (this.OnCancel != null) - { - - } -
- - -@code { - - /// - /// Gets or sets the model of the form, . - /// - [Parameter] - public T Model { get; set; } = default!; - - /// - /// Gets or sets a value indicating whether to hide collections or not. - /// - [Parameter] - public bool HideCollections { get; set; } - - /// - /// Gets or sets the event callback. - /// - [Parameter] - public EventCallback OnValidSubmit { get; set; } - - /// - /// Gets or sets the task which should be executed when the cancel button gets clicked. If null, no cancel button is shown. - /// - [Parameter] - public EventCallback? OnCancel { get; set; } -} + + + + +
+ + @if (this.OnRefresh != null) + { + + } + @if (this.OnCancel != null) + { + + } +
+
+
diff --git a/src/Web/Shared/Components/Form/AutoForm.razor.cs b/src/Web/Shared/Components/Form/AutoForm.razor.cs new file mode 100644 index 000000000..bfd70cbd6 --- /dev/null +++ b/src/Web/Shared/Components/Form/AutoForm.razor.cs @@ -0,0 +1,83 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.Shared.Components.Form; + +using Microsoft.AspNetCore.Components; + +/// +/// A generic auto-generated form for a model of type . +/// Renders all public properties via and optionally shows +/// a search box to filter fields when the model exposes many properties. +/// +/// The model type to edit. +public partial class AutoForm +{ + private string? _currentSearchTerm; + private string? _lastIncomingSearchTerm; + + /// + /// Gets or sets the model of the form, . + /// + [Parameter] + public T Model { get; set; } = default!; + + /// + /// Gets or sets a value indicating whether collection properties should be hidden. + /// + [Parameter] + public bool HideCollections { get; set; } + + /// + /// Gets or sets the callback invoked when the form is submitted with valid data. + /// + [Parameter] + public EventCallback OnValidSubmit { get; set; } + + /// + /// Gets or sets the callback invoked when the cancel button is clicked. + /// When , the cancel button is not rendered. + /// + [Parameter] + public EventCallback? OnCancel { get; set; } + + /// + /// Gets or sets the callback invoked when the refresh button is clicked. + /// When not set, the refresh button is not rendered. + /// + [Parameter] + public EventCallback? OnRefresh { get; set; } + + /// + /// Gets or sets an optional search term used to pre-filter the form fields. + /// The value is treated as an initial suggestion — the user can still type freely. + /// + [Parameter] + public string? SearchTerm { get; set; } + + /// + /// Gets a value indicating whether the search box should be displayed. + /// The box appears automatically when the model has many properties, + /// or when the user has already started typing a search term. + /// + private bool ShowSearch => + !string.IsNullOrWhiteSpace(this._currentSearchTerm) + || (this.Model?.GetType().GetProperties().Length ?? 0) > 15; + + /// + protected override void OnParametersSet() + { + base.OnParametersSet(); + + // Only sync the externally-supplied SearchTerm on actual changes to avoid + // clobbering what the user has already typed in the input box. + if (string.Equals(this.SearchTerm, this._lastIncomingSearchTerm, StringComparison.Ordinal)) + { + return; + } + + this._lastIncomingSearchTerm = this.SearchTerm; + this._currentSearchTerm = this.SearchTerm; + } +} diff --git a/src/Web/Shared/Components/Form/FlagsEnumField.razor b/src/Web/Shared/Components/Form/FlagsEnumField.razor index 25940ae84..eac810b75 100644 --- a/src/Web/Shared/Components/Form/FlagsEnumField.razor +++ b/src/Web/Shared/Components/Form/FlagsEnumField.razor @@ -1,21 +1,21 @@ -@using MUnique.OpenMU.Persistence +@using MUnique.OpenMU.Persistence @typeparam TValue where TValue : struct, Enum @inherits NotifyableInputBase
- + @item.GetName() @item.GetName() - +
diff --git a/src/Web/Shared/Components/Form/LookupField.razor b/src/Web/Shared/Components/Form/LookupField.razor index 5d02761ee..ff8931192 100644 --- a/src/Web/Shared/Components/Form/LookupField.razor +++ b/src/Web/Shared/Components/Form/LookupField.razor @@ -1,4 +1,4 @@ -@using System.ComponentModel.DataAnnotations +@using System.ComponentModel.DataAnnotations @using System.Diagnostics.CodeAnalysis @using MUnique.OpenMU.Web.Shared.Services @using MUnique.OpenMU.Persistence @@ -8,13 +8,13 @@
- + @if (item is null) { @@ -32,6 +32,6 @@ @CaptionFactory(item) - +
diff --git a/src/Web/Shared/Components/Form/Typeahead.razor b/src/Web/Shared/Components/Form/Typeahead.razor new file mode 100644 index 000000000..1f8beefad --- /dev/null +++ b/src/Web/Shared/Components/Form/Typeahead.razor @@ -0,0 +1,124 @@ +@typeparam TItem +@typeparam TValue + +
+
+ @if (_isLoading) + { +
+
+
+ } + + @if (IsMultiple && Values != null) + { +
+ @foreach (var val in Values) + { + var item = GetItemFromValue(val); + if (item != null) + { +
+ @if (SelectedTemplate != null) + { + @SelectedTemplate(item!) + } + else + { + @GetItemText(item) + } + +
+ } + } + +
+ } + else + { +
+ @if (Value != null && !_isOpen) + { +
+
+ @if (SelectedTemplate != null) + { + @SelectedTemplate(GetItemFromValue(Value!)!) + } + else + { + @GetItemText(GetItemFromValue(Value)) + } +
+ +
+ } + else + { + + } +
+ } + +
+ +
+
+ + @if (_isOpen) + { + + } +
diff --git a/src/Web/Shared/Components/Form/Typeahead.razor.cs b/src/Web/Shared/Components/Form/Typeahead.razor.cs new file mode 100644 index 000000000..1c1e6fac8 --- /dev/null +++ b/src/Web/Shared/Components/Form/Typeahead.razor.cs @@ -0,0 +1,356 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.Shared.Components.Form; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; + +/// +/// A custom typeahead component that supports single and multiple selections. +/// +/// The type of the item. +/// The type of the value. +public partial class Typeahead : ComponentBase, IDisposable +{ + private readonly Debouncer _searchDebouncer = new(300); + + private bool _isOpen; + private bool _isLoading; + private int _focusedIndex = -1; + private string _searchText = string.Empty; + private ElementReference _searchInput; + private CancellationTokenSource? _searchCts; + private List _suggestions = new(); + + /// + /// Gets or sets the debounce delay in milliseconds. + /// + [Parameter] + public int DebounceTime { get; set; } = 300; + + /// + /// Gets or sets the element id. + /// + [Parameter] + public string Id { get; set; } = string.Empty; + + /// + /// Gets or sets the placeholder. + /// + [Parameter] + public string Placeholder { get; set; } = string.Empty; + + /// + /// Gets or sets the search method. + /// + [Parameter] + public Func>> SearchMethod { get; set; } = null!; + + /// + /// Gets or sets the single value. + /// + [Parameter] + public TValue? Value { get; set; } + + /// + /// Gets or sets the value changed callback. + /// + [Parameter] + public EventCallback ValueChanged { get; set; } + + /// + /// Gets or sets the value expression. + /// + [Parameter] + public System.Linq.Expressions.Expression>? ValueExpression { get; set; } + + /// + /// Gets or sets the multiple values. + /// + [Parameter] + public IList? Values { get; set; } + + /// + /// Gets or sets the values changed callback. + /// + [Parameter] + public EventCallback?> ValuesChanged { get; set; } + + /// + /// Gets or sets the values expression. + /// + [Parameter] + public System.Linq.Expressions.Expression>>? ValuesExpression { get; set; } + + /// + /// Gets or sets a value indicating whether the dropdown is enabled. + /// + [Parameter] + public bool EnableDropDown { get; set; } + + /// + /// Gets or sets a value indicating whether to show the dropdown on focus. + /// + [Parameter] + public bool ShowDropDownOnFocus { get; set; } + + /// + /// Gets or sets the maximum number of suggestions. + /// + [Parameter] + public int MaximumSuggestions { get; set; } = 10; + + /// + /// Gets or sets the selected item template. + /// + [Parameter] + public RenderFragment? SelectedTemplate { get; set; } + + /// + /// Gets or sets the result item template. + /// + [Parameter] + public RenderFragment? ResultTemplate { get; set; } + + private bool IsMultiple => this.Values != null || this.ValuesChanged.HasDelegate; + + /// + public void Dispose() + { +#pragma warning disable VSTHRD103 + this._searchCts?.Cancel(); +#pragma warning restore VSTHRD103 + this._searchCts?.Dispose(); + this._searchDebouncer.Dispose(); + } + + /// + protected override void OnParametersSet() + { + base.OnParametersSet(); + if (!this.IsMultiple && this.Value != null) + { + var text = this.GetItemText(this.GetItemFromValue(this.Value!)); + if (this._searchText != text && !this._isOpen) + { + this._searchText = text; + } + } + else if (!this.IsMultiple && this.Value == null) + { + if (!this._isOpen) + { + this._searchText = string.Empty; + } + } + } + + private async Task OnSearchInputAsync() + { + if (!this.IsMultiple) + { + if (string.IsNullOrEmpty(this._searchText)) + { + this._searchDebouncer.Cancel(); + this._isOpen = false; + await this.SelectResultAsync(default).ConfigureAwait(false); + return; + } + } + + this._isOpen = true; + this._isLoading = true; + this.StateHasChanged(); + + _ = this._searchDebouncer.DebounceAsync(async () => + { + await this.InvokeAsync(async () => + { + await this.PerformSearchAsync().ConfigureAwait(false); + }).ConfigureAwait(true); + }); + } + + private async Task OnFocusAsync() + { + if (this.ShowDropDownOnFocus) + { + this._searchDebouncer.Cancel(); + await this.PerformSearchAsync().ConfigureAwait(false); + } + } + + private async Task OnFocusOutAsync(FocusEventArgs args) + { + // Allow time for click events on results to process + await Task.Delay(150).ConfigureAwait(true); + this._isOpen = false; + if (!this.IsMultiple) + { + this._searchText = this.Value == null ? string.Empty : this.GetItemText(this.GetItemFromValue(this.Value)); + } + else + { + this._searchText = string.Empty; + } + + this.StateHasChanged(); + } + + private async Task PerformSearchAsync() + { + if (this._searchCts != null) + { +#pragma warning disable VSTHRD103 + this._searchCts.Cancel(); +#pragma warning restore VSTHRD103 + } + + this._searchCts = new CancellationTokenSource(); + var token = this._searchCts.Token; + + this._isLoading = true; + this._isOpen = true; + this._focusedIndex = -1; + this.StateHasChanged(); + + try + { + var results = await this.SearchMethod(this._searchText).ConfigureAwait(true); + if (!token.IsCancellationRequested) + { + this._suggestions = results?.Take(this.MaximumSuggestions).ToList() ?? new List(); + + if (this.IsMultiple && this.Values != null) + { + this._suggestions = this._suggestions.Where(s => !this.Values.Contains((TValue)(object)s!)).ToList(); + } + } + } + catch (TaskCanceledException) + { + // Ignore + } + finally + { + if (!token.IsCancellationRequested) + { + this._isLoading = false; + this.StateHasChanged(); + } + } + } + + private async Task OnKeyDownAsync(KeyboardEventArgs args) + { + if (!this._isOpen && args.Key == "ArrowDown") + { + this._searchDebouncer.Cancel(); + await this.PerformSearchAsync().ConfigureAwait(false); + return; + } + + if (this._isOpen && this._suggestions.Any()) + { + if (args.Key == "ArrowDown") + { + this._focusedIndex = Math.Min(this._focusedIndex + 1, this._suggestions.Count - 1); + } + else if (args.Key == "ArrowUp") + { + this._focusedIndex = Math.Max(this._focusedIndex - 1, 0); + } + else if (args.Key == "Enter") + { + if (this._focusedIndex >= 0 && this._focusedIndex < this._suggestions.Count) + { + await this.SelectResultAsync(this._suggestions[this._focusedIndex]).ConfigureAwait(false); + } + } + else if (args.Key == "Escape") + { + this._isOpen = false; + } + } + } + + private async Task SelectResultAsync(TItem? item) + { + if (this.IsMultiple) + { + if (item != null) + { + var val = (TValue)(object)item; + var newValues = this.Values == null ? new List() : new List(this.Values); + if (!newValues.Contains(val)) + { + newValues.Add(val); + await this.ValuesChanged.InvokeAsync(newValues).ConfigureAwait(false); + } + } + + this._searchText = string.Empty; + this._isOpen = false; + } + else + { + if (item == null) + { + await this.ValueChanged.InvokeAsync(default).ConfigureAwait(false); + this._searchText = string.Empty; + } + else + { + await this.ValueChanged.InvokeAsync((TValue)(object)item).ConfigureAwait(false); + this._searchText = this.GetItemText(item); + } + + this._isOpen = false; + } + + this.StateHasChanged(); + } + + private async Task RemoveValueAsync(TValue value) + { + if (this.Values != null) + { + var newValues = new List(this.Values); + if (newValues.Remove(value)) + { + await this.ValuesChanged.InvokeAsync(newValues).ConfigureAwait(false); + } + } + } + + private async Task ClearValueAsync() + { + await this.ValueChanged.InvokeAsync(default).ConfigureAwait(false); + this._searchText = string.Empty; + this._isOpen = true; + this.StateHasChanged(); + await this.PerformSearchAsync().ConfigureAwait(false); + } + + private TItem? GetItemFromValue(TValue value) + { + if (value == null) + { + return default; + } + + return (TItem)(object)value; + } + + private string GetItemText(TItem? item) + { + return item?.ToString() ?? string.Empty; + } +} diff --git a/src/Web/Shared/Components/Form/Typeahead.razor.css b/src/Web/Shared/Components/Form/Typeahead.razor.css new file mode 100644 index 000000000..936115c0a --- /dev/null +++ b/src/Web/Shared/Components/Form/Typeahead.razor.css @@ -0,0 +1,137 @@ +.typeahead { + position: relative; + width: 100%; + flex: 1; + display: flex; + flex-direction: column; +} + +.typeahead-controls { + display: flex; + align-items: center; + position: relative; + padding: 0.375rem 2.2rem; + min-height: calc(1.5em + 0.75rem + 2px); + cursor: text; + border: 1px solid #ced4da; + border-radius: 0.25rem; + background-color: #fff; + transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; +} + +.typeahead-controls:focus-within { + color: #495057; + background-color: #fff; + border-color: #80bdff; + outline: 0; + box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); +} + +.typeahead-single-value, +.typeahead-multi-values { + width: 100%; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.25rem; +} + +.typeahead-multi-value { + display: inline-flex; + align-items: center; + background-color: #e9ecef; + border: 1px solid #ced4da; + border-radius: 0.2rem; + padding: 0.1rem 0.4rem; + font-size: 0.875rem; + line-height: 1.4; + white-space: nowrap; +} + +.typeahead-multi-value-remove { + background: none; + border: none; + color: #6c757d; + cursor: pointer; + font-size: 1rem; + font-weight: bold; + line-height: 1; + margin-left: 0.3rem; + padding: 0; + display: flex; + align-items: center; +} + +.typeahead-multi-value-remove:hover { + color: #dc3545; +} + +.typeahead-input { + flex: 1 1 auto; + min-width: 60px; + border: none; + outline: none; + background: transparent; + padding: 0; + margin: 0; + line-height: inherit; + font-family: inherit; + color: inherit; +} + +.typeahead-spinner { + position: absolute; + right: 0.75rem; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + z-index: 5; +} + +.typeahead-icon-left { + position: absolute; + left: 0.75rem; + top: 50%; + transform: translateY(-50%); + color: #6c757d; + pointer-events: none; + z-index: 5; + font-size: 0.9rem; +} + +.typeahead-selected-value { + flex: 1 1 auto; + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; +} + +.typeahead-selected-value-text { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.typeahead-single-value-remove { + padding: 0 0.5rem; + font-size: 1rem; + line-height: 1; +} + +.typeahead-results { + position: absolute; + width: 100%; + top: 100%; + max-height: 300px; + overflow-y: auto; + padding: 0.5rem 0; + margin-top: 0.125rem; + z-index: 1000; +} + +.typeahead-no-results { + pointer-events: none; +} \ No newline at end of file diff --git a/src/Web/Shared/Exports.cs b/src/Web/Shared/Exports.cs index 01508c8ec..8d3d04ff5 100644 --- a/src/Web/Shared/Exports.cs +++ b/src/Web/Shared/Exports.cs @@ -1,4 +1,4 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // @@ -39,7 +39,6 @@ private static IEnumerable SharedScripts { get { - yield return "_content/Blazored.Typeahead/blazored-typeahead.js"; yield return "_content/BlazorInputFile/inputfile.js"; } } @@ -48,7 +47,6 @@ private static IEnumerable SharedStylesheets { get { - yield return "_content/Blazored.Typeahead/blazored-typeahead.css"; yield return $"{Prefix}/css/shared.css"; } } diff --git a/src/Web/Shared/MUnique.OpenMU.Web.Shared.csproj b/src/Web/Shared/MUnique.OpenMU.Web.Shared.csproj index e0d844bab..faf5450cd 100644 --- a/src/Web/Shared/MUnique.OpenMU.Web.Shared.csproj +++ b/src/Web/Shared/MUnique.OpenMU.Web.Shared.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -54,7 +54,6 @@ - diff --git a/src/Web/Shared/Properties/Resources.Designer.cs b/src/Web/Shared/Properties/Resources.Designer.cs index 3a93d96b6..8303cb0d6 100644 --- a/src/Web/Shared/Properties/Resources.Designer.cs +++ b/src/Web/Shared/Properties/Resources.Designer.cs @@ -507,5 +507,14 @@ public static string Others return ResourceManager.GetString("Others", resourceCulture); } } + + /// + /// Looks up a localized string similar to Refresh. + /// + public static string Refresh { + get { + return ResourceManager.GetString("Refresh", resourceCulture); + } + } } } diff --git a/src/Web/Shared/Properties/Resources.resx b/src/Web/Shared/Properties/Resources.resx index 52800898d..ebc192540 100644 --- a/src/Web/Shared/Properties/Resources.resx +++ b/src/Web/Shared/Properties/Resources.resx @@ -1,4 +1,4 @@ - +