From 1217b38aeec378cb998670b6e74105325cee02e9 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 19:46:33 -0400 Subject: [PATCH 1/8] Make the form pickers keep typed text, start with large types and survive a lost circuit Closes #2639. --- .../Components/Forms/FieldWidget.razor | 2 - .../Components/Forms/PickerField.razor | 200 +++++++++++++----- .../Components/Layout/ReconnectModal.razor | 4 + .../Components/Pages/EntryEditor.razor | 22 +- src/FluentCMS.Admin/Styles/app.css | 5 +- src/FluentCMS.Admin/wwwroot/js/admin.js | 55 ++++- src/FluentCMS.Core/Media/IMediaLibrary.cs | 9 +- .../Media/MediaLibrary.cs | 13 +- .../Browser/BrowserSession.cs | 5 +- .../Browser/FormPickerTests.cs | 112 ++++++++-- .../Admin/GeneratedFormTests.cs | 1 - .../FluentCMS.Tests/Admin/PickerFieldTests.cs | 92 +++++++- 12 files changed, 411 insertions(+), 109 deletions(-) diff --git a/src/FluentCMS.Admin/Components/Forms/FieldWidget.razor b/src/FluentCMS.Admin/Components/Forms/FieldWidget.razor index 3747582e8..e7762fba8 100644 --- a/src/FluentCMS.Admin/Components/Forms/FieldWidget.razor +++ b/src/FluentCMS.Admin/Components/Forms/FieldWidget.razor @@ -13,7 +13,6 @@ Description="@Description" Error="@Error" Required="@Field.Required" - Options="@Options.ToArray()" @rendermode="InteractiveServer" /> return; } @@ -65,7 +64,6 @@ [Parameter, EditorRequired] public FieldDefinition Field { get; set; } = null!; [Parameter] public string? Value { get; set; } [Parameter] public string? Error { get; set; } - [Parameter] public IReadOnlyList Options { get; set; } = []; private string Name => $"Form.Values[{Field.Name}]"; diff --git a/src/FluentCMS.Admin/Components/Forms/PickerField.razor b/src/FluentCMS.Admin/Components/Forms/PickerField.razor index 333be49fc..90f7bb354 100644 --- a/src/FluentCMS.Admin/Components/Forms/PickerField.razor +++ b/src/FluentCMS.Admin/Components/Forms/PickerField.razor @@ -2,20 +2,22 @@ @using FluentCMS.Admin.Forms @using FluentCMS.Core.Content @using FluentCMS.Core.Media +@using FluentCMS.Core.Schema @using FluentCMS.Core.Search -@inject ISearch Search -@inject IEntryDirectory Entries -@inject IMediaLibrary Library +@using Microsoft.Extensions.DependencyInjection +@implements IDisposable +@inject IServiceScopeFactory Scopes @inject IStringLocalizer L @* A reference or media field of a generated form (FRM-01). Over the circuit it searches as the person types and keeps the choice in a - hidden input the static form posts; before the circuit attaches, or once it is lost, it is the plain select, which posts the same. *@ + hidden input the static form posts; before the circuit attaches it is the plain select, which posts the same. Once the circuit is + lost the search stops, the choice still posts, and the page keeps what was typed (admin.js). *@ @if (!RendererInfo.IsInteractive) { _hits = []; + private IReadOnlyList _fallback = []; [Parameter, EditorRequired] public string Field { get; set; } = string.Empty; [Parameter, EditorRequired] public string Kind { get; set; } = EntryKind; @@ -94,70 +105,165 @@ [Parameter] public string? Description { get; set; } [Parameter] public string? Error { get; set; } [Parameter] public bool Required { get; set; } - // An array, not an interface: parameters of an interactive island cross into the circuit as JSON, which an interface type cannot. - [Parameter] public FieldOption[] Options { get; set; } = []; private string Name => $"Form.Values[{Field}]"; private string ListId => _id + "-list"; - protected override void OnParametersSet() + // Every read goes through a scope of its own: the pickers of one form load at once, and searches overlap as the person types, + // while a DbContext takes one query at a time. + protected override async Task OnParametersSetAsync() { _value = string.IsNullOrWhiteSpace(Value) ? null : Value; - _chosenLabel = Options.FirstOrDefault(option => option.Value == _value)?.Label; + await using var scope = Scopes.CreateAsyncScope(); + var services = scope.ServiceProvider; + if (RendererInfo.IsInteractive) + { + _chosenLabel = _value is null ? null : await LabelAsync(services, _value); + return; + } + + _fallback = Kind == MediaKind ? await MediaChoicesAsync(services) : await EntryChoicesAsync(services); } private async Task SearchAsync(ChangeEventArgs change) { - _text = change.Value?.ToString() ?? string.Empty; - _hits = _text.Trim().Length == 0 ? [] : Kind == MediaKind ? await MediaAsync(_text.Trim()) : await EntriesAsync(_text.Trim()); - _searched = _text.Trim().Length > 0; + var search = ++_search; + var text = (change.Value?.ToString() ?? string.Empty).Trim(); + try + { + await Task.Delay(SearchPause, _disposed.Token); + if (search != _search) + { + return; + } + + List hits = []; + if (text.Length > 0) + { + await using var scope = Scopes.CreateAsyncScope(); + hits = Kind == MediaKind ? await MediaAsync(scope.ServiceProvider, text) : await EntriesAsync(scope.ServiceProvider, text); + } + + // Only the newest search shows its results, whatever order the searches end in. + if (search == _search) + { + (_hits, _searched) = (hits, text.Length > 0); + } + } + catch (OperationCanceledException) when (_disposed.IsCancellationRequested) + { + } } - private async Task> EntriesAsync(string text) + private async Task> EntriesAsync(IServiceProvider services, string text) { if (Target is null) { return []; } - var found = await Search.SearchAsync(new SearchQuery(text) { ContentTypes = [Target], Limit = MaxHits }); + var found = await services.GetRequiredService().SearchAsync(new SearchQuery(text) { ContentTypes = [Target], Limit = MaxHits }, _disposed.Token); if (!found.IsSuccess) { return []; } + var nameField = await NameFieldAsync(services); + var entries = services.GetRequiredService(); var hits = new List(); foreach (var hit in found.Value.Hits.Where(hit => hit.Via is null).DistinctBy(hit => hit.EntryId)) { - var entry = await Entries.GetAsync(hit.EntryId); - var name = entry.IsSuccess ? FirstText(entry.Value.Latest.Fields) : null; - hits.Add(new PickerHit(hit.EntryId.ToString(), L["{0} ({1}, {2})", name ?? hit.ContentType, StateLabel(hit.State), hit.EntryId.ToString()[..8]], null)); + var entry = await entries.GetAsync(hit.EntryId, _disposed.Token); + hits.Add(new PickerHit(hit.EntryId.ToString(), EntryLabel(entry.IsSuccess ? NameOf(entry.Value, nameField) : null, hit.State, hit.EntryId), null)); } return hits; } - private async Task> MediaAsync(string text) + private async Task> MediaAsync(IServiceProvider services, string text) + { + var assets = await services.GetRequiredService().ListAsync(new MediaQuery { Text = text, Newest = MaxHits }, _disposed.Token); + return [.. assets.Select(asset => new PickerHit(asset.Id.ToString(), MediaLabel(asset), asset.ContentType.StartsWith("image/", StringComparison.Ordinal) ? AdminRoutes.MediaFile(asset.Id) : null))]; + } + + private async Task LabelAsync(IServiceProvider services, string value) + { + if (!Guid.TryParse(value, out var id)) + { + return null; + } + + if (Kind == MediaKind) + { + var asset = await services.GetRequiredService().GetAsync(id, _disposed.Token); + return asset.IsSuccess ? asset.Value.Latest.Metadata.FileName : null; + } + + var entry = await services.GetRequiredService().GetAsync(id, _disposed.Token); + return entry.IsSuccess ? EntryLabel(NameOf(entry.Value, await NameFieldAsync(services)), entry.Value.State, id) : null; + } + + private async Task> EntryChoicesAsync(IServiceProvider services) + { + if (Target is null) + { + return []; + } + + var listed = await services.GetRequiredService().ListAsync(Target, _disposed.Token); + var choices = listed.IsSuccess + ? listed.Value.OrderByDescending(entry => entry.Id).Take(FallbackChoices).Select(entry => new FieldOption(entry.Id.ToString(), EntryLabel(null, entry.State, entry.Id))).ToList() + : []; + return await WithChosenAsync(services, choices); + } + + private async Task> MediaChoicesAsync(IServiceProvider services) { - var assets = await Library.ListAsync(new MediaQuery()); - return - [ - .. assets - .Where(asset => asset.FileName.Contains(text, StringComparison.CurrentCultureIgnoreCase) || asset.Folder.Contains(text, StringComparison.OrdinalIgnoreCase)) - .Reverse() - .Take(MaxHits) - .Select(asset => new PickerHit( - asset.Id.ToString(), - asset.Folder.Length == 0 ? asset.FileName : $"{asset.FileName} ({asset.Folder})", - asset.ContentType.StartsWith("image/", StringComparison.Ordinal) ? AdminRoutes.MediaFile(asset.Id) : null)), - ]; + var assets = await services.GetRequiredService().ListAsync(new MediaQuery { Newest = FallbackChoices }, _disposed.Token); + return await WithChosenAsync(services, [.. assets.Select(asset => new FieldOption(asset.Id.ToString(), MediaLabel(asset)))]); + } + + // The chosen value is always among the select's options, named as the search names it. + private async Task> WithChosenAsync(IServiceProvider services, List choices) + { + if (_value is not null) + { + choices.RemoveAll(choice => choice.Value == _value); + choices.Insert(0, new FieldOption(_value, await LabelAsync(services, _value) ?? _value)); + } + + return choices; + } + + // An entry is named by the first text field of its type, in the order the type defines them. + private async Task NameFieldAsync(IServiceProvider services) + { + var type = Target is null ? null : await services.GetRequiredService().GetAsync(Target, _disposed.Token); + return type is { IsSuccess: true } ? type.Value.Definition.Fields.FirstOrDefault(field => field.Type == "text")?.Name : null; + } + + private static string? NameOf(Entry entry, string? nameField) + { + if (nameField is null || !entry.Latest.Fields.TryGetProperty(nameField, out var value)) + { + return null; + } + + var text = FactValues.IsFact(value) ? value.GetProperty(FluentCMS.Core.Schema.FactMetadata.Value) : value; + return text.ValueKind == JsonValueKind.String ? text.GetString() : null; } + private string EntryLabel(string? name, EntryState state, Guid id) => name is null + ? L["{0} ({1})", id.ToString()[..8], StateLabel(state)] + : L["{0} ({1}, {2})", name, StateLabel(state), id.ToString()[..8]]; + + private static string MediaLabel(MediaAssetSummary asset) => asset.Folder.Length == 0 ? asset.FileName : $"{asset.FileName} ({asset.Folder})"; + private void Choose(PickerHit hit) { (_value, _chosenLabel) = (hit.Value, hit.Label); - (_hits, _text, _searched) = ([], string.Empty, false); + Reset(); } private void Clear() @@ -165,27 +271,19 @@ (_value, _chosenLabel) = (null, null); } - private void OnKeyDown(KeyboardEventArgs key) + private void Reset() { - if (key.Key == "Escape") - { - (_hits, _searched) = ([], false); - } + (_hits, _searched) = ([], false); + _search++; } - // What names an entry for a person: its first text value, or an entity's first text fact. - private static string? FirstText(JsonElement fields) + private void OnKeyDown(KeyboardEventArgs key) { - foreach (var property in fields.EnumerateObject()) + if (key.Key == "Escape") { - var value = property.Value.ValueKind == JsonValueKind.Object && property.Value.TryGetProperty("value", out var fact) ? fact : property.Value; - if (value.ValueKind == JsonValueKind.String && !Guid.TryParse(value.GetString(), out _)) - { - return value.GetString(); - } + (_hits, _searched) = ([], false); + _search++; } - - return null; } private string StateLabel(EntryState state) => state switch @@ -197,5 +295,11 @@ _ => L["archived"], }; + public void Dispose() + { + _disposed.Cancel(); + _disposed.Dispose(); + } + private sealed record PickerHit(string Value, string Label, string? Preview); } diff --git a/src/FluentCMS.Admin/Components/Layout/ReconnectModal.razor b/src/FluentCMS.Admin/Components/Layout/ReconnectModal.razor index 2d1dc37e7..834113515 100644 --- a/src/FluentCMS.Admin/Components/Layout/ReconnectModal.razor +++ b/src/FluentCMS.Admin/Components/Layout/ReconnectModal.razor @@ -19,6 +19,10 @@

@L["The session has been paused."]

+
+

@L["The search in this form stopped. Save the form as it is, or reload the page to search again; reloading loses what you typed."]

+ +

@L["Could not resume the session."]

diff --git a/src/FluentCMS.Admin/Components/Pages/EntryEditor.razor b/src/FluentCMS.Admin/Components/Pages/EntryEditor.razor index 7411f9cec..58c7f3794 100644 --- a/src/FluentCMS.Admin/Components/Pages/EntryEditor.razor +++ b/src/FluentCMS.Admin/Components/Pages/EntryEditor.razor @@ -7,12 +7,10 @@ @using FluentCMS.Admin.Forms @using FluentCMS.Core.ChangeSets @using FluentCMS.Core.Content -@using FluentCMS.Core.Media @using FluentCMS.Core.Schema @inject IContentTypeDirectory ContentTypes @inject IEntryDirectory Entries @inject IEntryEdits Edits -@inject IMediaLibrary Media @inject FluentCMS.Core.Identity.IUserDirectory Users @inject FieldTypeRegistry FieldTypes @inject NavigationManager Navigation @@ -80,7 +78,7 @@ @foreach (var field in type.Definition.Fields) { - + @if (type.Definition.IsEntity) { @@ -111,7 +109,6 @@ @code { private ContentType? _type; private Entry? _entry; - private Dictionary> _options = new(StringComparer.Ordinal); private Dictionary _fieldErrors = new(StringComparer.Ordinal); private Dictionary> _factProblems = new(StringComparer.Ordinal); private HashSet _unsourced = new(StringComparer.Ordinal); @@ -222,7 +219,7 @@ return true; } - // Until the pickers search (F-SRC), a reference offers every entry of its target type and a media field every asset. + // A reference or media field's picker loads its own choices. private async Task LoadOptionsAsync() { if (_type!.Definition.IsEntity) @@ -232,21 +229,6 @@ _ownerIds = [.. users.Select(user => user.Id)]; _owners = [.. users.Select(user => new FieldOption(user.Id.ToString(), user.IsDisabled ? L["{0} (disabled)", user.DisplayName] : user.DisplayName))]; } - - foreach (var field in _type!.Definition.Fields) - { - if (field.Type == "reference" && FieldValues.Setting(field, "target") is { } target) - { - var entries = await Entries.ListAsync(target); - _options[field.Name] = entries.IsSuccess - ? [.. entries.Value.Select(entry => new FieldOption(entry.Id.ToString(), L["{0} (version {1})", entry.Id.ToString()[..8], entry.LatestVersion]))] - : []; - } - else if (field.Type == "media") - { - _options[field.Name] = [.. (await Media.ListAsync(new MediaQuery())).Select(asset => new FieldOption(asset.Id.ToString(), asset.FileName))]; - } - } } private async Task SaveAsync() diff --git a/src/FluentCMS.Admin/Styles/app.css b/src/FluentCMS.Admin/Styles/app.css index 13546c10a..84d0bd3e7 100644 --- a/src/FluentCMS.Admin/Styles/app.css +++ b/src/FluentCMS.Admin/Styles/app.css @@ -183,7 +183,7 @@ /* Blazor reports a lost circuit as a state class on this element (ReconnectModal.razor); each part names the state it shows in. */ #components-reconnect-modal { display: none; - &:is(.components-reconnect-show, .components-reconnect-failed, .components-reconnect-paused, .components-reconnect-resume-failed) { + &:is(.components-reconnect-show, .components-reconnect-failed, .components-reconnect-paused, .components-reconnect-resume-failed, .components-reconnect-lost) { display: flex; } & [data-reconnect-when] { @@ -193,7 +193,8 @@ &.components-reconnect-retrying [data-reconnect-when="retrying"], &.components-reconnect-failed [data-reconnect-when="failed"], &.components-reconnect-paused [data-reconnect-when="paused"], - &.components-reconnect-resume-failed [data-reconnect-when="resume-failed"] { + &.components-reconnect-resume-failed [data-reconnect-when="resume-failed"], + &.components-reconnect-lost [data-reconnect-when="lost"] { display: block; } } diff --git a/src/FluentCMS.Admin/wwwroot/js/admin.js b/src/FluentCMS.Admin/wwwroot/js/admin.js index 3e1d7be1a..8bfc4ae82 100644 --- a/src/FluentCMS.Admin/wwwroot/js/admin.js +++ b/src/FluentCMS.Admin/wwwroot/js/admin.js @@ -409,13 +409,16 @@ function onRoveKeydown(event) { return true; } - // From a search box that controls a list, ArrowDown enters the list at its first item. - if (target.matches('[data-ui="rove-input"]') && event.key === "ArrowDown") { + // From a search box that controls a list, ArrowDown enters the list at its first item. Enter never submits the form around + // the box: it chooses the only match, or enters the list when there are more. + if (target.matches('[data-ui="rove-input"]') && (event.key === "ArrowDown" || event.key === "Enter")) { const list = document.getElementById(target.getAttribute("aria-controls")); const items = list ? roveItems(list) : []; - if (items.length === 0) return false; + if (event.key === "Enter") event.preventDefault(); + if (items.length === 0) return event.key === "Enter"; event.preventDefault(); - rove(items, 0); + if (event.key === "Enter" && items.length === 1) items[0].click(); + else rove(items, 0); return true; } @@ -428,6 +431,27 @@ function onRoveKeydown(event) { return true; } +// --------------------------------------------------------------------------------------------- +// Pickers (PickerField.razor): the search box owns its text, so a choice empties it here, and focus goes back to the box after a +// choice, Clear or Escape, before Blazor removes the element that had it. + +const pickerBox = (element) => element.closest?.("[data-picker]")?.querySelector('[data-ui="rove-input"]'); + +function onPickerClick(event) { + const option = event.target.closest?.('[data-picker] [role="option"]'); + const clear = event.target.closest?.('[data-picker] [data-action="clear"]'); + const box = pickerBox(option ?? clear ?? document.body); + if (!box) return; + if (option) box.value = ""; + box.focus(); +} + +function onPickerEscape(event) { + if (event.key !== "Escape") return; + const box = pickerBox(event.target); + if (box && event.target !== box) box.focus(); +} + // --------------------------------------------------------------------------------------------- // Theme: the root carries "light" or "dark", or neither to follow the system. The choice goes into // the cookie the host reads (ThemePreference.cs), so the next page arrives with the class already set. @@ -445,8 +469,10 @@ function chooseTheme(theme) { // --------------------------------------------------------------------------------------------- // Reconnect: Blazor reports its state as a class on #components-reconnect-modal (ReconnectModal.razor) // and leaves the buttons, and the reload after a rejected circuit, to the page, as its default display does. +// A page whose form holds a picker is not reloaded, which would lose what was typed: its searches stop, the +// choices still post, and the person saves the form or reloads it themselves. -const reconnectStates = ["show", "hide", "retrying", "failed", "rejected", "paused", "resume-failed"].map((state) => `components-reconnect-${state}`); +const reconnectStates = ["show", "hide", "retrying", "failed", "rejected", "paused", "resume-failed", "lost"].map((state) => `components-reconnect-${state}`); function showReconnectState(state) { const modal = document.getElementById("components-reconnect-modal"); @@ -454,12 +480,22 @@ function showReconnectState(state) { modal?.classList.add(`components-reconnect-${state}`); } +function reloadOrKeepForm() { + if (!document.querySelector("form [data-picker]")) { + location.reload(); + return; + } + + document.querySelectorAll('[data-picker] [data-ui="rove-input"]').forEach((box) => { box.disabled = true; }); + showReconnectState("lost"); +} + async function retryConnection() { document.removeEventListener("visibilitychange", retryWhenVisible); showReconnectState("show"); try { if (await Blazor.reconnect() || await Blazor.resumeCircuit()) showReconnectState("hide"); - else location.reload(); + else reloadOrKeepForm(); } catch { showReconnectState("failed"); document.addEventListener("visibilitychange", retryWhenVisible); @@ -469,7 +505,7 @@ async function retryConnection() { async function resumeConnection() { try { if (await Blazor.resumeCircuit()) showReconnectState("hide"); - else location.reload(); + else reloadOrKeepForm(); } catch { showReconnectState("resume-failed"); } @@ -480,7 +516,7 @@ function retryWhenVisible() { } function onReconnectStateChanged(event) { - if (event.detail?.state === "rejected") location.reload(); + if (event.detail?.state === "rejected") reloadOrKeepForm(); if (event.detail?.state === "failed") document.addEventListener("visibilitychange", retryWhenVisible); } @@ -510,6 +546,8 @@ document.addEventListener("click", (event) => { const reconnect = event.target.closest?.("[data-reconnect]"); if (reconnect) onReconnectClick(reconnect); + onPickerClick(event); + closeOnBackdropClick(event); }); @@ -520,6 +558,7 @@ document.addEventListener("keydown", (event) => { if (!(target instanceof Element)) return; if (event.key === "Escape") hideTooltips(); + onPickerEscape(event); if (onShortcutKeydown(event)) return; if (onRoveKeydown(event)) return; diff --git a/src/FluentCMS.Core/Media/IMediaLibrary.cs b/src/FluentCMS.Core/Media/IMediaLibrary.cs index d32472a5b..965383983 100644 --- a/src/FluentCMS.Core/Media/IMediaLibrary.cs +++ b/src/FluentCMS.Core/Media/IMediaLibrary.cs @@ -31,7 +31,14 @@ public interface IMediaLibrary public sealed record NewAsset(Stream Content, string ContentType, AssetMetadata Metadata); -public sealed record MediaQuery(string? Folder = null, bool IncludeSubfolders = false, string? Tag = null); +public sealed record MediaQuery(string? Folder = null, bool IncludeSubfolders = false, string? Tag = null) +{ + // Only assets whose file name or folder holds the text, whatever its case. + public string? Text { get; init; } + + // Only this many of the newest assets, newest first; every asset, oldest first, when null. + public int? Newest { get; init; } +} // An asset with its pointers and the latest version. public sealed record MediaAsset(Guid Id, Guid SiteId, int LatestVersion, int? PublishedVersion, MediaAssetVersion Latest); diff --git a/src/FluentCMS.Infrastructure/Media/MediaLibrary.cs b/src/FluentCMS.Infrastructure/Media/MediaLibrary.cs index 0828dd90e..d3aa3a7af 100644 --- a/src/FluentCMS.Infrastructure/Media/MediaLibrary.cs +++ b/src/FluentCMS.Infrastructure/Media/MediaLibrary.cs @@ -202,11 +202,18 @@ public async Task> ListAsync(MediaQuery query, latest = latest.Where(version => version.Tags.Contains(wanted)); } - var rows = await ( + if (query.Text?.Trim().ToLowerInvariant() is { Length: > 0 } text) + { + latest = latest.Where(version => version.FileName.ToLower().Contains(text) || version.Folder.Contains(text)); + } + + var joined = from version in latest join asset in context.MediaAssets.AsNoTracking() on version.AssetId equals asset.Id - orderby asset.Id - select new { asset.PublishedVersion, Version = version }) + select new { asset.Id, asset.PublishedVersion, Version = version }; + var rows = await (query.Newest is { } newest + ? joined.OrderByDescending(row => row.Id).Take(newest) + : joined.OrderBy(row => row.Id)) .ToListAsync(cancellationToken); return rows diff --git a/tests/FluentCMS.IntegrationTests/Browser/BrowserSession.cs b/tests/FluentCMS.IntegrationTests/Browser/BrowserSession.cs index 4857bf7c5..a24cd2ba7 100644 --- a/tests/FluentCMS.IntegrationTests/Browser/BrowserSession.cs +++ b/tests/FluentCMS.IntegrationTests/Browser/BrowserSession.cs @@ -27,10 +27,11 @@ private BrowserSession(TemporaryInstall install, AdminHost host, Guid ownerId, I public string BaseUrl => Host.Client.BaseAddress!.ToString().TrimEnd('/'); - public static async Task StartAsync() + // Settings such as a TemporaryDatabase's configuration run the host on another provider. + public static async Task StartAsync(IDictionary? settings = null) { var install = new TemporaryInstall(); - var host = await AdminHost.StartAsync(install); + var host = await AdminHost.StartAsync(install, settings: settings); var ownerId = await host.CreateFirstAdministratorAsync(); var playwright = await Playwright.CreateAsync(); var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions { Headless = true }); diff --git a/tests/FluentCMS.IntegrationTests/Browser/FormPickerTests.cs b/tests/FluentCMS.IntegrationTests/Browser/FormPickerTests.cs index d5e6a2b75..0959b3463 100644 --- a/tests/FluentCMS.IntegrationTests/Browser/FormPickerTests.cs +++ b/tests/FluentCMS.IntegrationTests/Browser/FormPickerTests.cs @@ -1,20 +1,24 @@ using System.Text.Json; using System.Text.RegularExpressions; +using FluentCMS.Admin.Components.Forms; using FluentCMS.Core.ChangeSets; using FluentCMS.Core.Content; using FluentCMS.Core.Schema; +using FluentCMS.Infrastructure.Persistence; using Microsoft.Playwright; namespace FluentCMS.IntegrationTests.Browser; public sealed class FormPickerTests { - // FRM-01 by keyboard alone: a person finds the referenced product by typing, chooses it with the arrow keys and saves the page. + // FRM-01 by keyboard alone, from the top of the page: a person finds the referenced product by typing at full speed, chooses it + // with the keyboard and saves. The target type holds 500 entries, more than a circuit's first message could carry as options. [BrowserFact] - public async Task A_person_picks_a_referenced_entry_by_search_with_the_keyboard_and_saves() + public async Task A_person_picks_a_referenced_entry_by_search_with_the_keyboard_alone_and_saves() { - await using var session = await BrowserSession.StartAsync(); - var drill = await ModelAsync(session); + await using var database = await TemporaryDatabase.CreateAsync(Provider); + await using var session = await BrowserSession.StartAsync(database.Configuration); + var drill = await ModelAsync(session, products: 500); var page = session.Page; await session.SignInWithKeyboardAsync(); @@ -22,21 +26,25 @@ public async Task A_person_picks_a_referenced_entry_by_search_with_the_keyboard_ var search = page.Locator("[data-picker=product] input[type=search]"); await Assertions.Expect(search).ToBeVisibleAsync(); - await page.Locator("input[name='Form.Values[title]']").FocusAsync(); + await TabToAsync(page, "input[name='Form.Values[title]']"); await page.Keyboard.TypeAsync("Drills"); await page.Keyboard.PressAsync("Tab"); await Assertions.Expect(search).ToBeFocusedAsync(); - await page.Keyboard.TypeAsync("cordless"); + await page.Keyboard.TypeAsync("cordless drill"); + await Assertions.Expect(search).ToHaveValueAsync("cordless drill"); await Assertions.Expect(page.Locator("[data-picker=product] [role=option]")).ToHaveCountAsync(1); - await page.Keyboard.PressAsync("ArrowDown"); - await Assertions.Expect(page.Locator("[data-picker=product] [role=option]")).ToBeFocusedAsync(); + + // Enter chooses the only match and keeps focus in the box; it never submits the form. await page.Keyboard.PressAsync("Enter"); await Assertions.Expect(page.Locator("[data-picker=product] input[type=hidden]")).ToHaveValueAsync(drill.ToString()); + await Assertions.Expect(page.Locator("[data-testid=picker-choice]")).ToContainTextAsync("Cordless drill"); + await Assertions.Expect(search).ToBeFocusedAsync(); + await Assertions.Expect(search).ToHaveValueAsync(""); + Assert.EndsWith("/content/product-page/new", page.Url); - await page.Locator("textarea[name='Form.Rationale']").FocusAsync(); + await TabToAsync(page, "textarea[name='Form.Rationale']"); await page.Keyboard.TypeAsync("Add the drills page."); - await page.Keyboard.PressAsync("Tab"); - await page.Keyboard.PressAsync("Tab"); + await TabToAsync(page, "[data-action=save]"); await page.Keyboard.PressAsync("Enter"); await page.WaitForURLAsync(new Regex("/sets/[0-9a-f-]{36}\\?done=saved$")); @@ -46,7 +54,76 @@ public async Task A_person_picks_a_referenced_entry_by_search_with_the_keyboard_ Assert.Equal(drill.ToString(), entry.Latest.Fields.GetProperty("product").GetString()); } - private static async Task ModelAsync(BrowserSession session) + // With no pause, every key runs a search, and the searches overlap; each reads through its own scope, so none ends the circuit. + [BrowserFact] + public async Task Overlapping_searches_never_end_the_circuit_and_the_newest_one_shows() + { + await using var database = await TemporaryDatabase.CreateAsync(Provider); + await using var session = await BrowserSession.StartAsync(database.Configuration); + await ModelAsync(session, products: 20); + var page = session.Page; + var pause = PickerField.SearchPause; + PickerField.SearchPause = TimeSpan.Zero; + try + { + await session.SignInWithKeyboardAsync(); + await page.GotoAsync($"{session.BaseUrl}/content/product-page/new"); + var search = page.Locator("[data-picker=product] input[type=search]"); + await Assertions.Expect(search).ToBeVisibleAsync(); + + await search.PressSequentiallyAsync("product number 1"); + await Assertions.Expect(page.Locator("[data-picker=product] [role=option]").First).ToContainTextAsync("Product number 1"); + await search.FillAsync(""); + await search.PressSequentiallyAsync("cordless"); + await Assertions.Expect(page.Locator("[data-picker=product] [role=option]")).ToHaveCountAsync(1); + await Assertions.Expect(page.Locator("#components-reconnect-modal")).ToBeHiddenAsync(); + } + finally + { + PickerField.SearchPause = pause; + } + } + + // A circuit the server no longer knows ends the search, not the form: nothing reloads and what was typed stays. + [BrowserFact] + public async Task A_lost_circuit_keeps_the_form_and_what_was_typed() + { + await using var session = await BrowserSession.StartAsync(); + await ModelAsync(session, products: 1); + var page = session.Page; + + await session.SignInWithKeyboardAsync(); + await page.GotoAsync($"{session.BaseUrl}/content/product-page/new"); + await Assertions.Expect(page.Locator("[data-picker=product] input[type=search]")).ToBeVisibleAsync(); + await page.Locator("input[name='Form.Values[title]']").FillAsync("Typed before the loss"); + await page.EvaluateAsync("document.dispatchEvent(new CustomEvent('components-reconnect-state-changed', { detail: { state: 'rejected' } }))"); + + await Assertions.Expect(page.Locator("[data-testid=reconnect-lost]")).ToBeVisibleAsync(); + await Assertions.Expect(page.Locator("input[name='Form.Values[title]']")).ToHaveValueAsync("Typed before the loss"); + await Assertions.Expect(page.Locator("[data-picker=product] input[type=search]")).ToBeDisabledAsync(); + } + + private static string Provider => string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(PostgresFactAttribute.VariableName)) + ? DatabaseProviders.Sqlite + : DatabaseProviders.PostgreSql; + + // Presses Tab until the element has focus, as a keyboard user reaches a field. + private static async Task TabToAsync(IPage page, string selector) + { + for (var press = 0; press < 40; press++) + { + if (await page.Locator(selector).EvaluateAsync("element => element === document.activeElement")) + { + return; + } + + await page.Keyboard.PressAsync("Tab"); + } + + Assert.Fail($"Tab never reached {selector}."); + } + + private static async Task ModelAsync(BrowserSession session, int products) { await session.Host.AsUserAsync(session.OwnerId, async types => (await types.CreateAsync(new NewContentType( "Product", "product", new ContentTypeDefinition([new FieldDefinition("name", "text", required: true)])), TestChangeSetTargets.ContentModel)).Value); @@ -58,8 +135,15 @@ await session.Host.AsUserAsync(session.Owner new FieldDefinition("title", "text", required: true), new FieldDefinition("product", "reference", settings: JsonDocument.Parse("""{"target": "product"}""").RootElement), ])), TestChangeSetTargets.ContentModel)).Value); - await session.Host.AsUserAsync(session.OwnerId, async entries => - (await entries.CreateAsync(new NewEntry("product", JsonDocument.Parse("""{"name": "Hammer"}""").RootElement))).Value); + await session.Host.AsUserAsync(session.OwnerId, async entries => + { + for (var number = 1; number < products; number++) + { + await entries.CreateAsync(new NewEntry("product", JsonDocument.Parse($$"""{"name": "Product number {{number}}"}""").RootElement)); + } + + return true; + }); var drill = await session.Host.AsUserAsync(session.OwnerId, async entries => (await entries.CreateAsync(new NewEntry("product", JsonDocument.Parse("""{"name": "Cordless drill"}""").RootElement))).Value); return drill.Id; diff --git a/tests/FluentCMS.Tests/Admin/GeneratedFormTests.cs b/tests/FluentCMS.Tests/Admin/GeneratedFormTests.cs index 54db0e101..9ab8c3d69 100644 --- a/tests/FluentCMS.Tests/Admin/GeneratedFormTests.cs +++ b/tests/FluentCMS.Tests/Admin/GeneratedFormTests.cs @@ -136,7 +136,6 @@ public void Each_field_type_renders_its_widget_inside_a_form_field(string type, var cut = context.Render(widget => widget .Add(w => w.Field, Field(type, type == "enum" ? """{"values": ["red", "blue"]}""" : "{}", required: true)) - .Add(w => w.Options, [new FieldOption("0192f3a1-7c2e-7a4b-9d3e-1f2a3b4c5d6e", "Tools")]) .Add(w => w.Error, "Wrong.")); var control = cut.Find(selector); diff --git a/tests/FluentCMS.Tests/Admin/PickerFieldTests.cs b/tests/FluentCMS.Tests/Admin/PickerFieldTests.cs index b70f7ff34..897067187 100644 --- a/tests/FluentCMS.Tests/Admin/PickerFieldTests.cs +++ b/tests/FluentCMS.Tests/Admin/PickerFieldTests.cs @@ -2,9 +2,12 @@ using Bunit; using FluentCMS.Admin.Components.Forms; using FluentCMS.Admin.Forms; +using FluentCMS.Core; +using FluentCMS.Core.ChangeSets; using FluentCMS.Core.Content; using FluentCMS.Core.Media; using FluentCMS.Core.Results; +using FluentCMS.Core.Schema; using FluentCMS.Core.Search; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.DependencyInjection; @@ -15,6 +18,7 @@ public sealed class PickerFieldTests { internal static readonly Guid Drill = Guid.Parse("0192f3a1-7c2e-7a4b-9d3e-1f2a3b4c5d6e"); internal static readonly Guid Hero = Guid.Parse("0192f3a1-7c2e-7a4b-9d3e-1f2a3b4c5d6f"); + internal static readonly Guid Other = Guid.Parse("0192f3a1-7c2e-7a4b-9d3e-1f2a3b4c5d70"); [Fact] public void Before_the_circuit_attaches_a_picker_is_the_plain_select() @@ -25,9 +29,37 @@ public void Before_the_circuit_attaches_a_picker_is_the_plain_select() Assert.Equal("Form.Values[product]", cut.Find("select").GetAttribute("name")); Assert.True(cut.Find($"option[value='{Drill}']").HasAttribute("selected")); + Assert.Contains("Cordless drill (published, 0192f3a1)", cut.Find($"option[value='{Drill}']").TextContent); + Assert.Contains("0192f3a1 (draft)", cut.Find($"option[value='{Other}']").TextContent); Assert.Empty(cut.FindAll("input[type=search]")); } + [Fact] + public void The_island_takes_no_list_of_choices_so_its_start_stays_small_however_many_there_are() + { + var parameters = typeof(PickerField).GetProperties().Where(property => property.IsDefined(typeof(ParameterAttribute), inherit: false)).Select(property => property.PropertyType); + + Assert.All(parameters, type => Assert.True(type == typeof(string) || type == typeof(bool), $"{type} is not a scalar.")); + } + + [Fact] + public void Only_the_newest_search_shows_its_results() + { + using var context = Context(interactive: true); + var search = (FakeSearch)context.Services.GetRequiredService(); + search.Hold = new TaskCompletionSource(); + + var cut = context.Render(EntryPicker(chosen: false)); + cut.Find("input[type=search]").Input("dri"); + search.Hold = null; + cut.Find("input[type=search]").Input("saw"); + Assert.Contains("Nothing matches.", cut.Markup); + + search.Release(); + Assert.Empty(cut.FindAll("[role=option]")); + Assert.Contains("Nothing matches.", cut.Markup); + } + [Fact] public void An_entry_picker_searches_the_target_type_and_posts_the_chosen_id() { @@ -80,6 +112,8 @@ public void A_media_picker_finds_assets_by_file_name_with_a_preview() .Add(p => p.Label, "hero")); cut.Find("input[type=search]").Input("HERO"); + var media = (FakeMedia)context.Services.GetRequiredService(); + Assert.Equal(new MediaQuery { Text = "HERO", Newest = 10 }, media.Queries[^1]); var option = cut.Find("[role=option]"); Assert.Contains("hero.jpg (products)", option.TextContent); Assert.Equal($"media/{Hero}/file", option.QuerySelector("img")!.GetAttribute("src")); @@ -89,6 +123,7 @@ public void A_media_picker_finds_assets_by_file_name_with_a_preview() internal static AdminTestContext Context(bool interactive) { + PickerField.SearchPause = TimeSpan.Zero; var context = new AdminTestContext(); AddServices(context); context.Renderer.SetRendererInfo(new RendererInfo(interactive ? "Server" : "Static", interactive)); @@ -100,6 +135,7 @@ internal static void AddServices(AdminTestContext context) context.Services.AddSingleton(new FakeSearch()); context.Services.AddSingleton(new FakeEntries()); context.Services.AddSingleton(new FakeMedia()); + context.Services.AddSingleton(new FakeTypes()); } private static Action> EntryPicker(bool chosen = true) @@ -109,31 +145,67 @@ private static Action> EntryPic .Add(p => p.Kind, PickerField.EntryKind) .Add(p => p.Target, "product") .Add(p => p.Value, chosen ? Drill.ToString() : null) - .Add(p => p.Label, "product") - .Add(p => p.Options, [new FieldOption(Drill.ToString(), "Cordless drill")]); + .Add(p => p.Label, "product"); } private sealed class FakeSearch : ISearch { + private readonly List _held = []; + public List Queries { get; } = []; - public Task> SearchAsync(SearchQuery query, CancellationToken cancellationToken = default) + // While set, a search waits until Release, as a slow query would. + public TaskCompletionSource? Hold { get; set; } + + public async Task> SearchAsync(SearchQuery query, CancellationToken cancellationToken = default) { Queries.Add(query); + if (Hold is { } hold) + { + _held.Add(hold); + await hold.Task; + } + IReadOnlyList hits = query.Text == "dri" ? [new SearchHit(Drill, "product", EntryState.Published, 2, 2, 1.0, null)] : []; - return Task.FromResult(Result.Success(new SearchResults(hits))); + return Result.Success(new SearchResults(hits)); } + + public void Release() => _held.ForEach(hold => hold.TrySetResult()); + } + + private sealed class FakeTypes : IContentTypeDirectory + { + private static readonly FieldTypeRegistry Registry = new ServiceCollection().AddFluentCmsCore().BuildServiceProvider().GetRequiredService(); + + public Task> GetAsync(string slug, CancellationToken cancellationToken = default) + { + return Task.FromResult(ContentType.Create(Guid.NewGuid(), slug, slug, new ContentTypeDefinition([new FieldDefinition("sku", "number"), new FieldDefinition("name", "text")], isEntity: true), Registry)); + } + + public Task> ListAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task> GetPublishedAsync(string slug, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task> GetVersionAsync(string slug, int version, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task> CreateAsync(NewContentType contentType, ChangeSetTarget changeSet, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task> ReviseAsync(string slug, int baseVersion, ContentTypeDefinition definition, ChangeSetTarget changeSet, CancellationToken cancellationToken = default) => throw new NotSupportedException(); } private sealed class FakeEntries : IEntryDirectory { public Task> GetAsync(Guid id, CancellationToken cancellationToken = default) { - var fields = JsonDocument.Parse("""{"name": {"value": "Cordless drill", "source": null}}""").RootElement; + var fields = JsonDocument.Parse("""{"sku": {"value": 1234, "source": null}, "name": {"value": "Cordless drill", "source": null}}""").RootElement; return Task.FromResult(Result.Success(FluentCMS.Core.Content.Entry.Restore(id, Guid.NewGuid(), Guid.NewGuid(), "product", 2, EntryState.Published, new EntryVersion(id, 2, 1, fields, DateTimeOffset.UnixEpoch)))); } - public Task>> ListAsync(string contentType, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task>> ListAsync(string contentType, CancellationToken cancellationToken = default) + { + IReadOnlyList entries = [new EntrySummary(Drill, 2, 2, EntryState.Published), new EntrySummary(Other, 1, null, EntryState.Draft)]; + return Task.FromResult(Result>.Success(entries)); + } public Task> GetVersionAsync(Guid id, int version, CancellationToken cancellationToken = default) => throw new NotSupportedException(); @@ -154,14 +226,18 @@ private sealed class FakeMedia : IMediaLibrary { public long MaxBytes => 50 * 1024 * 1024; + public List Queries { get; } = []; + public Task> ListAsync(MediaQuery query, CancellationToken cancellationToken = default) { - IReadOnlyList assets = + Queries.Add(query); + MediaAssetSummary[] assets = [ new(Hero, 1, null, "hero.jpg", "image/jpeg", 10, "products", [], true), new(Guid.NewGuid(), 1, null, "manual.pdf", "application/pdf", 10, "", [], false), ]; - return Task.FromResult(assets); + IReadOnlyList matching = [.. assets.Where(asset => query.Text is null || asset.FileName.Contains(query.Text, StringComparison.OrdinalIgnoreCase))]; + return Task.FromResult(matching); } public Task> UploadAsync(NewAsset asset, CancellationToken cancellationToken = default) => throw new NotSupportedException(); From 0d2cea3f0421c9b2edd9d6fcb385580171321280 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 19:49:29 -0400 Subject: [PATCH 2/8] Test the reconnect display's lost state --- tests/FluentCMS.Tests/Admin/ReconnectModalTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/FluentCMS.Tests/Admin/ReconnectModalTests.cs b/tests/FluentCMS.Tests/Admin/ReconnectModalTests.cs index d2e0da186..03d146773 100644 --- a/tests/FluentCMS.Tests/Admin/ReconnectModalTests.cs +++ b/tests/FluentCMS.Tests/Admin/ReconnectModalTests.cs @@ -18,7 +18,7 @@ public void Renders_the_element_Blazor_looks_for_with_a_part_for_each_reconnect_ Assert.Equal("status", modal.GetAttribute("role")); Assert.Equal("polite", modal.GetAttribute("aria-live")); Assert.True(modal.HasAttribute("data-permanent")); - Assert.Equal(["show", "retrying", "failed", "paused", "resume-failed"], parts.Select(part => part.GetAttribute("data-reconnect-when"))); + Assert.Equal(["show", "retrying", "failed", "paused", "lost", "resume-failed"], parts.Select(part => part.GetAttribute("data-reconnect-when"))); Assert.Equal("Connection lost. Rejoining the server…", parts[0].TextContent); } @@ -45,6 +45,7 @@ public void Each_failure_offers_its_button_for_admin_js() Assert.Equal("retry", cut.Find("[data-reconnect-when=failed] button").GetAttribute("data-reconnect")); Assert.Equal("resume", cut.Find("[data-reconnect-when=paused] button").GetAttribute("data-reconnect")); Assert.Equal("reload", cut.Find("[data-reconnect-when=resume-failed] button").GetAttribute("data-reconnect")); + Assert.Equal("reload", cut.Find("[data-reconnect-when=lost] button").GetAttribute("data-reconnect")); Assert.All(cut.FindAll("button"), button => Assert.Equal("button", button.GetAttribute("type"))); } From 9f0e9117213cc27e88bcea5a0000c1f9f5cb9709 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 20:22:43 -0400 Subject: [PATCH 3/8] Keep Enter to the latest search, match media text in .NET, and keep any page with typed input --- .../Components/Forms/PickerField.razor | 15 ++-- .../Components/Layout/ReconnectModal.razor | 2 +- src/FluentCMS.Admin/wwwroot/js/admin.js | 19 ++++- .../Media/MediaLibrary.cs | 13 +++- .../Browser/FormPickerTests.cs | 77 ++++++++++++++++++- .../Media/MediaLibraryTests.cs | 25 ++++++ 6 files changed, 133 insertions(+), 18 deletions(-) diff --git a/src/FluentCMS.Admin/Components/Forms/PickerField.razor b/src/FluentCMS.Admin/Components/Forms/PickerField.razor index 90f7bb354..dfe4ffa48 100644 --- a/src/FluentCMS.Admin/Components/Forms/PickerField.razor +++ b/src/FluentCMS.Admin/Components/Forms/PickerField.razor @@ -50,7 +50,8 @@ data-field="@Field" /> @if (_hits.Count > 0) { -
+ @* data-input names the keystroke these matches answer: admin.js lets Enter choose only when it is the box's latest one. *@ +
@foreach (var (hit, index) in _hits.Select((hit, index) => (hit, index))) {
-

@L["The search in this form stopped. Save the form as it is, or reload the page to search again; reloading loses what you typed."]

+

@L["The connection to the server was lost. What you typed stays on the page: save the form as it is, or reload the page, which loses what you typed. Searches in the form stopped."]

diff --git a/src/FluentCMS.Admin/wwwroot/js/admin.js b/src/FluentCMS.Admin/wwwroot/js/admin.js index 8bfc4ae82..7f5e75ae0 100644 --- a/src/FluentCMS.Admin/wwwroot/js/admin.js +++ b/src/FluentCMS.Admin/wwwroot/js/admin.js @@ -413,7 +413,9 @@ function onRoveKeydown(event) { // the box: it chooses the only match, or enters the list when there are more. if (target.matches('[data-ui="rove-input"]') && (event.key === "ArrowDown" || event.key === "Enter")) { const list = document.getElementById(target.getAttribute("aria-controls")); - const items = list ? roveItems(list) : []; + // Matches for an earlier keystroke are not the answer to what the box holds now, so Enter waits for the new ones. + const fresh = !list?.dataset.input || list.dataset.input === target.dataset.inputs; + const items = list && fresh ? roveItems(list) : []; if (event.key === "Enter") event.preventDefault(); if (items.length === 0) return event.key === "Enter"; event.preventDefault(); @@ -469,8 +471,8 @@ function chooseTheme(theme) { // --------------------------------------------------------------------------------------------- // Reconnect: Blazor reports its state as a class on #components-reconnect-modal (ReconnectModal.razor) // and leaves the buttons, and the reload after a rejected circuit, to the page, as its default display does. -// A page whose form holds a picker is not reloaded, which would lose what was typed: its searches stop, the -// choices still post, and the person saves the form or reloads it themselves. +// A page whose form holds typed input is not reloaded, which would lose it: pickers stop searching, every field +// still posts, and the person saves the form or reloads it themselves. const reconnectStates = ["show", "hide", "retrying", "failed", "rejected", "paused", "resume-failed", "lost"].map((state) => `components-reconnect-${state}`); @@ -480,8 +482,15 @@ function showReconnectState(state) { modal?.classList.add(`components-reconnect-${state}`); } +const typed = (field) => field.type === "checkbox" || field.type === "radio" + ? field.checked !== field.defaultChecked + : field instanceof HTMLSelectElement + ? [...field.options].some((option) => option.selected !== option.defaultSelected) + : field.type !== "hidden" && field.value !== field.defaultValue; + function reloadOrKeepForm() { - if (!document.querySelector("form [data-picker]")) { + const fields = [...document.querySelectorAll("form input, form textarea, form select")]; + if (!document.querySelector("form [data-picker]") && !fields.some(typed)) { location.reload(); return; } @@ -577,6 +586,8 @@ document.addEventListener("keydown", (event) => { document.addEventListener("input", (event) => { if (event.target.matches?.('[data-ui="command-input"]')) filterCommand(event.target); + // Counted as the server counts them (PickerField.razor), so a list can say which keystroke it answers. + if (event.target.matches?.('[data-picker] [data-ui="rove-input"]')) event.target.dataset.inputs = String(Number(event.target.dataset.inputs ?? 0) + 1); }); document.addEventListener("pointermove", onCommandPointerMove); diff --git a/src/FluentCMS.Infrastructure/Media/MediaLibrary.cs b/src/FluentCMS.Infrastructure/Media/MediaLibrary.cs index d3aa3a7af..9a10e1dcf 100644 --- a/src/FluentCMS.Infrastructure/Media/MediaLibrary.cs +++ b/src/FluentCMS.Infrastructure/Media/MediaLibrary.cs @@ -202,9 +202,18 @@ public async Task> ListAsync(MediaQuery query, latest = latest.Where(version => version.Tags.Contains(wanted)); } - if (query.Text?.Trim().ToLowerInvariant() is { Length: > 0 } text) + // Matched here rather than in SQL: SQLite's lower() folds ASCII letters only, so "Ärzte.jpg" would miss "ärzte". Only the names + // are read for it, and only the matching assets after. + if (query.Text?.Trim() is { Length: > 0 } text) { - latest = latest.Where(version => version.FileName.ToLower().Contains(text) || version.Folder.Contains(text)); + var names = await latest.Select(version => new { version.AssetId, version.FileName, version.Folder }).ToListAsync(cancellationToken); + var matching = names + .Where(name => name.FileName.Contains(text, StringComparison.CurrentCultureIgnoreCase) || name.Folder.Contains(text, StringComparison.OrdinalIgnoreCase)) + .Select(name => name.AssetId) + .OrderDescending() + .Take(query.Newest ?? int.MaxValue) + .ToList(); + latest = latest.Where(version => matching.Contains(version.AssetId)); } var joined = diff --git a/tests/FluentCMS.IntegrationTests/Browser/FormPickerTests.cs b/tests/FluentCMS.IntegrationTests/Browser/FormPickerTests.cs index 0959b3463..ceaf54fdd 100644 --- a/tests/FluentCMS.IntegrationTests/Browser/FormPickerTests.cs +++ b/tests/FluentCMS.IntegrationTests/Browser/FormPickerTests.cs @@ -84,23 +84,92 @@ public async Task Overlapping_searches_never_end_the_circuit_and_the_newest_one_ } } - // A circuit the server no longer knows ends the search, not the form: nothing reloads and what was typed stays. + // Enter answers what the box holds now: matches still shown for an earlier word are not chosen while the new search runs. + [BrowserFact] + public async Task Enter_never_chooses_a_match_from_an_earlier_search() + { + await using var session = await BrowserSession.StartAsync(); + await ModelAsync(session, products: 3); + var page = session.Page; + var pause = PickerField.SearchPause; + PickerField.SearchPause = TimeSpan.FromSeconds(2); + try + { + await session.SignInWithKeyboardAsync(); + await page.GotoAsync($"{session.BaseUrl}/content/product-page/new"); + var search = page.Locator("[data-picker=product] input[type=search]"); + await search.PressSequentiallyAsync("cordless"); + await Assertions.Expect(page.Locator("[data-picker=product] [role=option]")).ToHaveCountAsync(1); + + await search.PressAsync("Control+a"); + await search.PressSequentiallyAsync("product number"); + await search.PressAsync("Enter"); + + await Assertions.Expect(page.Locator("[data-picker=product] input[type=hidden]")).ToHaveValueAsync(""); + await Assertions.Expect(search).ToHaveValueAsync("product number"); + Assert.EndsWith("/content/product-page/new", page.Url); + } + finally + { + PickerField.SearchPause = pause; + } + } + + // A circuit the server no longer knows ends the search, not the form: nothing reloads, what was typed stays, and the choice made + // before the loss is saved with the form. [BrowserFact] public async Task A_lost_circuit_keeps_the_form_and_what_was_typed() { await using var session = await BrowserSession.StartAsync(); - await ModelAsync(session, products: 1); + var drill = await ModelAsync(session, products: 1); var page = session.Page; await session.SignInWithKeyboardAsync(); await page.GotoAsync($"{session.BaseUrl}/content/product-page/new"); - await Assertions.Expect(page.Locator("[data-picker=product] input[type=search]")).ToBeVisibleAsync(); + var search = page.Locator("[data-picker=product] input[type=search]"); + await search.PressSequentiallyAsync("cordless"); + await page.Locator("[data-picker=product] [role=option]").ClickAsync(); + await Assertions.Expect(page.Locator("[data-picker=product] input[type=hidden]")).ToHaveValueAsync(drill.ToString()); await page.Locator("input[name='Form.Values[title]']").FillAsync("Typed before the loss"); await page.EvaluateAsync("document.dispatchEvent(new CustomEvent('components-reconnect-state-changed', { detail: { state: 'rejected' } }))"); await Assertions.Expect(page.Locator("[data-testid=reconnect-lost]")).ToBeVisibleAsync(); await Assertions.Expect(page.Locator("input[name='Form.Values[title]']")).ToHaveValueAsync("Typed before the loss"); - await Assertions.Expect(page.Locator("[data-picker=product] input[type=search]")).ToBeDisabledAsync(); + await Assertions.Expect(search).ToBeDisabledAsync(); + + await page.Locator("textarea[name='Form.Rationale']").FillAsync("Saved after the loss."); + await page.Locator("[data-action=save]").ClickAsync(); + await page.WaitForURLAsync(new Regex("/sets/[0-9a-f-]{36}\\?done=saved$")); + var setId = Guid.Parse(new Uri(page.Url).AbsolutePath["/sets/".Length..]); + var set = await session.Host.AsUserAsync(session.OwnerId, async sets => (await sets.GetAsync(setId)).Value); + var entry = await session.Host.AsUserAsync(session.OwnerId, async entries => (await entries.GetAsync(set.Operations[0].ItemId)).Value); + Assert.Equal(("Typed before the loss", drill.ToString()), (entry.Latest.Fields.GetProperty("title").GetString(), entry.Latest.Fields.GetProperty("product").GetString())); + } + + // Any page with typed input is kept, such as a rejection reason on the set view, whose diff is an island too. + [BrowserFact] + public async Task A_lost_circuit_keeps_a_page_with_typed_input_even_without_a_picker() + { + await using var session = await BrowserSession.StartAsync(); + var page = session.Page; + var type = await session.Host.AsUserAsync(session.OwnerId, async types => (await types.CreateAsync(new NewContentType( + "Brand", "brand", new ContentTypeDefinition([new FieldDefinition("name", "text")])), TestChangeSetTargets.ContentModel)).Value); + var set = await session.Host.AsUserAsync(session.OwnerId, async sets => + { + var opened = (await sets.OpenAsync(new NewChangeSet("Add the brand type.", []))).Value; + await sets.AddOperationAsync(opened.Id, ChangeOperation.Create(OperationKind.Create, ItemKind.Schema, type.Id, null, 1).Value); + await sets.TransitionAsync(opened.Id, ChangeSetState.ChecksRunning); + return (await sets.TransitionAsync(opened.Id, ChangeSetState.AwaitingReview)).Value; + }); + + await session.SignInWithKeyboardAsync(); + await page.GotoAsync($"{session.BaseUrl}/sets/{set.Id}"); + await page.Locator("[data-action=reject]").ClickAsync(); + await page.Locator("textarea[name='Reject.Reason']").FillAsync("Typed before the loss"); + await page.EvaluateAsync("document.dispatchEvent(new CustomEvent('components-reconnect-state-changed', { detail: { state: 'rejected' } }))"); + + await Assertions.Expect(page.Locator("[data-testid=reconnect-lost]")).ToBeVisibleAsync(); + await Assertions.Expect(page.Locator("textarea[name='Reject.Reason']")).ToHaveValueAsync("Typed before the loss"); } private static string Provider => string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(PostgresFactAttribute.VariableName)) diff --git a/tests/FluentCMS.IntegrationTests/Media/MediaLibraryTests.cs b/tests/FluentCMS.IntegrationTests/Media/MediaLibraryTests.cs index 665d009b3..6fdc677f7 100644 --- a/tests/FluentCMS.IntegrationTests/Media/MediaLibraryTests.cs +++ b/tests/FluentCMS.IntegrationTests/Media/MediaLibraryTests.cs @@ -132,6 +132,31 @@ async Task> ListAsync(MediaQuery query) => Assert.Equal(["", "products", "products/drills"], await LibraryAsync(scenario, library => library.FoldersAsync())); } + // The pickers search by text: any letter matches whatever its case, as SQLite's own lower() would not do, and Newest keeps the + // newest few, newest first. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Assets_list_by_text_in_their_name_or_folder_whatever_its_case_and_the_newest_first(string provider) + { + await using var scenario = await EntryScenario.CreateAsync(provider); + var doctors = (await UploadAsync(scenario, MediaFiles.Jpeg(), Metadata("Ärzte.png"))).Value.Id; + var drill = (await UploadAsync(scenario, MediaFiles.Jpeg(), Metadata("drill.png", "products/drills"))).Value.Id; + var saw = (await UploadAsync(scenario, MediaFiles.Jpeg(), Metadata("saw.png", "products"))).Value.Id; + var range = (await UploadAsync(scenario, MediaFiles.Jpeg(), Metadata("range.png", "products"))).Value.Id; + + async Task> ListAsync(MediaQuery query) => + (await LibraryAsync(scenario, library => library.ListAsync(query))).Select(asset => asset.Id).ToList(); + + Assert.Equal([doctors], await ListAsync(new MediaQuery { Text = "ärzte" })); + Assert.Equal([doctors], await ListAsync(new MediaQuery { Text = "ÄRZTE" })); + Assert.Equal([drill], await ListAsync(new MediaQuery { Text = "DRILL" })); + Assert.Equal(new[] { drill, saw, range }.Order(), await ListAsync(new MediaQuery { Text = "Products" })); + Assert.Equal([range, saw], await ListAsync(new MediaQuery { Text = "products", Newest = 2 })); + Assert.Equal([range, saw], await ListAsync(new MediaQuery { Newest = 2 })); + Assert.Empty(await ListAsync(new MediaQuery { Text = "hammer" })); + } + [Theory] [InlineData(DatabaseProviders.Sqlite)] [PostgresData] From 802af4df8e1047c6a17e3db0c11e581eef8a729a Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 20:42:05 -0400 Subject: [PATCH 4/8] Compare the text a picker's matches answer, and keep its choice with the circuit --- .../Components/Forms/FieldWidget.razor | 4 +- .../Components/Forms/PickerField.razor | 47 +++++++++++-------- src/FluentCMS.Admin/wwwroot/js/admin.js | 6 +-- .../Browser/FormPickerTests.cs | 34 ++++++++++++++ 4 files changed, 66 insertions(+), 25 deletions(-) diff --git a/src/FluentCMS.Admin/Components/Forms/FieldWidget.razor b/src/FluentCMS.Admin/Components/Forms/FieldWidget.razor index e7762fba8..5fe56e225 100644 --- a/src/FluentCMS.Admin/Components/Forms/FieldWidget.razor +++ b/src/FluentCMS.Admin/Components/Forms/FieldWidget.razor @@ -5,7 +5,9 @@ @* One field of a generated form (FRM-01): the widget its type takes, inside a FormField with the field's description and error. *@ @if (Field.Type is "reference" or "media") { - @L["Choose…"] @foreach (var option in _fallback) { - + } } else {
- +
- @if (_value is null) + @if (Choice?.Value is null) { @L["Nothing chosen."] } else { - @(_chosenLabel ?? _value) + @(Choice.Label ?? Choice.Value) }
@@ -50,13 +50,13 @@ data-field="@Field" /> @if (_hits.Count > 0) { - @* data-input names the keystroke these matches answer: admin.js lets Enter choose only when it is the box's latest one. *@ -
+ @* data-for names the text these matches answer: admin.js lets Enter choose only when the box still holds it. *@ +
@foreach (var (hit, index) in _hits.Select((hit, index) => (hit, index))) {