Просмотр исходного кода

Added global search key navigation

Tim Jones 3 дней назад
Родитель
Сommit
62f1d4642f
2 измененных файлов с 99 добавлено и 23 удалено
  1. 31 8
      Shared.Rcl/Components/GlobalSearch.razor
  2. 68 15
      Tests.E2e/GlobalSearchTests.cs

+ 31 - 8
Shared.Rcl/Components/GlobalSearch.razor

@@ -1,6 +1,7 @@
 @* Global resource search — header-level palette.
 
    Keyboard:  ↑/↓ move the highlight, Enter navigates, Esc clears.
+   Blur:      Clicking anywhere outside the input closes the dropdown.
 *@
 
 @using Microsoft.AspNetCore.Components.Web
@@ -23,7 +24,8 @@
            @bind="_query"
            @bind:event="oninput"
            @bind:after="RunSearchAsync"
-           @onkeydown="OnKeyDown"/>
+           @onkeydown="OnKeyDown"
+           @onfocusout="OnFocusOut"/>
 
     @if (!string.IsNullOrWhiteSpace(_query))
     {
@@ -67,15 +69,23 @@
 </div>
 
 @code {
+    // Brief delay on focus-out so a click on a result button can fire first.
+    // Browser event order is mousedown → blur → click, so the focus-out fires
+    // before the click handler on the button. The delay lets the click register
+    // before we close the dropdown.
+    private const int _blurCloseDelayMs = 150;
+
     private string _query = string.Empty;
     private IReadOnlyList<SearchResult> _results = [];
-    private IReadOnlyList<Resource>? _resources;
     private int _highlightedIndex;
 
     private async Task RunSearchAsync()
     {
-        _resources ??= await Repo.GetAllOfTypeAsync<Resource>();
-        _results = GlobalSearchService.Search(_resources, _query);
+        // Reload every search so newly added / edited / deleted resources show up
+        // without a hard page refresh. The repo layer is already in-memory, so
+        // this is a cheap dictionary lookup, not a disk read.
+        var resources = await Repo.GetAllOfTypeAsync<Resource>();
+        _results = GlobalSearchService.Search(resources, _query);
         _highlightedIndex = 0;
     }
 
@@ -99,19 +109,32 @@
                 break;
 
             case "Escape":
-                _query = string.Empty;
-                _results = [];
-                _highlightedIndex = 0;
+                Close();
                 break;
         }
     }
 
+    private async Task OnFocusOut()
+    {
+        await Task.Delay(_blurCloseDelayMs);
+        if (!string.IsNullOrEmpty(_query))
+        {
+            Close();
+            StateHasChanged();
+        }
+    }
+
     private void OnResultSelected(SearchResult r)
+    {
+        Close();
+        Nav.NavigateTo(r.Url);
+    }
+
+    private void Close()
     {
         _query = string.Empty;
         _results = [];
         _highlightedIndex = 0;
-        Nav.NavigateTo(r.Url);
     }
 
     private static string Sanitize(string value) =>

+ 68 - 15
Tests.E2e/GlobalSearchTests.cs

@@ -12,30 +12,83 @@ public class GlobalSearchTests(
     private readonly ITestOutputHelper _output = output;
 
     [Fact]
-    public async Task User_Can_Search_For_A_Service_And_Navigate_To_It() {
+    public async Task User_Can_Search_For_Resources_Of_Every_Kind_And_Navigate_To_One() {
         (IBrowserContext context, IPage page) = await CreatePageAsync();
-        var serviceName = $"e2e-search-{Guid.NewGuid():N}"[..20];
+
+        // Shared random token in every seeded name so a single fragment hits
+        // every kind, while a kind-prefixed query isolates one.
+        var token = Guid.NewGuid().ToString("N")[..6];
+
+        var seed = new (string Kind, string Name)[] {
+            ("server",      $"srv-{token}"),
+            ("switch",      $"sw-{token}"),
+            ("firewall",    $"fw-{token}"),
+            ("router",      $"rt-{token}"),
+            ("accesspoint", $"ap-{token}"),
+            ("ups",         $"ups-{token}"),
+            ("desktop",     $"dt-{token}"),
+            ("laptop",      $"lt-{token}"),
+            ("system",      $"sys-{token}"),
+            ("service",     $"svc-{token}"),
+        };
 
         try {
-            // Seed: create a service via the existing services list flow
             await page.GotoAsync(_fixture.BaseUrl);
+            await new MainLayoutPom(page).AssertLoadedAsync();
+
+            // ───── Seed: one resource of every kind ─────
+            await page.GotoAsync($"{_fixture.BaseUrl}/servers/list");
+            await new ServersListPom(page).AddServerAsync($"srv-{token}");
+
+            await page.GotoAsync($"{_fixture.BaseUrl}/switches/list");
+            await new SwitchListPom(page).AddSwitchAsync($"sw-{token}");
+
+            await page.GotoAsync($"{_fixture.BaseUrl}/firewalls/list");
+            await new FirewallsListPom(page).AddFirewallAsync($"fw-{token}");
+
+            await page.GotoAsync($"{_fixture.BaseUrl}/routers/list");
+            await new RouterListPom(page).AddRouterAsync($"rt-{token}");
 
-            var layout = new MainLayoutPom(page);
-            await layout.AssertLoadedAsync();
-            await layout.GotoServicesAsync();
+            await page.GotoAsync($"{_fixture.BaseUrl}/accesspoints/list");
+            await new AccessPointsListPom(page).AddAccessPointAsync($"ap-{token}");
 
-            var servicesList = new ServicesListPom(page);
-            await servicesList.AssertLoadedAsync();
-            await servicesList.AddServiceAsync(serviceName);
+            await page.GotoAsync($"{_fixture.BaseUrl}/ups/list");
+            await new UpsListPom(page).AddUpsAsync($"ups-{token}");
+
+            await page.GotoAsync($"{_fixture.BaseUrl}/desktops/list");
+            await new DesktopsListPom(page).AddDesktopAsync($"dt-{token}");
+
+            await page.GotoAsync($"{_fixture.BaseUrl}/laptops/list");
+            await new LaptopListPom(page).AddLaptopAsync($"lt-{token}");
+
+            await page.GotoAsync($"{_fixture.BaseUrl}/systems/list");
+            await new SystemsListPom(page).AddSystemAsync($"sys-{token}");
+
+            await page.GotoAsync($"{_fixture.BaseUrl}/services/list");
+            await new ServicesListPom(page).AddServiceAsync($"svc-{token}");
 
-            // Use global search from the header — partial prefix of the unique name
             var search = new GlobalSearchPom(page);
-            await search.SearchAsync(serviceName[..12]);
-            await search.AssertResultExistsAsync("service", serviceName);
 
-            // Click the result and assert navigation to the service detail page
-            await search.ClickResultAsync("service", serviceName);
-            await page.WaitForURLAsync($"**/resources/services/{serviceName}");
+            // ───── Per-kind search: each unique name surfaces its own kind ─────
+            foreach ((string Kind, string Name) entry in seed) {
+                await search.SearchAsync(entry.Name);
+                await search.AssertResultExistsAsync(entry.Kind, entry.Name);
+            }
+
+            // ───── Cross-kind search: shared token surfaces results from
+            //       multiple kinds in the same dropdown. Top N is capped at 8;
+            //       within an equal-score tier ties break alphabetically by name,
+            //       so the kinds asserted here are those guaranteed to land
+            //       inside the cap given the seeded name prefixes.
+            await search.SearchAsync(token);
+            await search.AssertResultExistsAsync("service", $"svc-{token}");
+            await search.AssertResultExistsAsync("server",  $"srv-{token}");
+            await search.AssertResultExistsAsync("switch",  $"sw-{token}");
+
+            // ───── Click navigates to the resource page ─────
+            await search.SearchAsync($"svc-{token}");
+            await search.ClickResultAsync("service", $"svc-{token}");
+            await page.WaitForURLAsync($"**/resources/services/svc-{token}");
         }
         catch (Exception) {
             _output.WriteLine("TEST FAILED — Capturing diagnostics");