diff --git a/src/FluentCMS.Admin/Components/Forms/FieldWidget.razor b/src/FluentCMS.Admin/Components/Forms/FieldWidget.razor index 3747582e8..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") { - return; } @@ -65,7 +66,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..44180aa23 100644 --- a/src/FluentCMS.Admin/Components/Forms/PickerField.razor +++ b/src/FluentCMS.Admin/Components/Forms/PickerField.razor @@ -2,42 +2,45 @@ @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) { } else {
- +
- @if (_value is null) + @if (Choice?.Value is null) { @L["Nothing chosen."] } else { - @(_chosenLabel ?? _value) + @(Choice.Label ?? Choice.Value) }
+ @* The box owns its text: the server never writes it back, so a key typed while a search runs is never lost. admin.js empties it + after a choice and keeps focus in it. *@ @if (_hits.Count > 0) { -
+ @* 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))) {
+
+

@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."]

+ +

@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..6da90a475 100644 --- a/src/FluentCMS.Admin/wwwroot/js/admin.js +++ b/src/FluentCMS.Admin/wwwroot/js/admin.js @@ -409,13 +409,18 @@ 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; + // Matches for an earlier text are not the answer to what the box holds now, so Enter waits for the new ones. + const fresh = list?.dataset.for === undefined || list.dataset.for === target.value.trim(); + const items = list && fresh ? roveItems(list) : []; + 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 +433,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 +471,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 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"].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 +482,37 @@ 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() { + const fields = [...document.querySelectorAll("form input, form textarea, form select")]; + if (!document.querySelector("form [data-picker]") && !fields.some(typed)) { + location.reload(); + return; + } + + document.querySelectorAll('[data-picker] [data-ui="rove-input"], [data-picker] [role="option"]').forEach((control) => { control.disabled = true; }); + // Without a circuit nothing can render the next page's islands, so an enhanced navigation would keep this page's: every link + // now loads its page in full, and so do Back and Forward, which Blazor handles without looking at that attribute. + document.documentElement.setAttribute("data-enhance-nav", "false"); + // Loaded with GET, so going back to an entry a form's post made never asks to post it again. + window.addEventListener("popstate", (event) => { + event.stopImmediatePropagation(); + location.replace(location.href); + }, { capture: 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 +522,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 +533,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 +563,8 @@ document.addEventListener("click", (event) => { const reconnect = event.target.closest?.("[data-reconnect]"); if (reconnect) onReconnectClick(reconnect); + onPickerClick(event); + closeOnBackdropClick(event); }); @@ -520,6 +575,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..9a10e1dcf 100644 --- a/src/FluentCMS.Infrastructure/Media/MediaLibrary.cs +++ b/src/FluentCMS.Infrastructure/Media/MediaLibrary.cs @@ -202,11 +202,27 @@ public async Task> ListAsync(MediaQuery query, latest = latest.Where(version => version.Tags.Contains(wanted)); } - var rows = await ( + // 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) + { + 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 = 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..796d654c3 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,281 @@ 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; + } + } + + // 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 paused and resumed, as after a laptop sleeps, keeps the choice; keys typed while it was paused never reach the server, + // and Enter still chooses afterwards because it compares the text the matches answer, not a count of keys. + [BrowserFact] + public async Task A_paused_and_resumed_circuit_keeps_the_choice_and_enter_still_chooses() + { + await using var session = await BrowserSession.StartAsync(); + var drill = await ModelAsync(session, products: 3); + var page = session.Page; + + await session.SignInWithKeyboardAsync(); + await page.GotoAsync($"{session.BaseUrl}/content/product-page/new"); + var search = page.Locator("[data-picker=product] input[type=search]"); + var choice = page.Locator("[data-picker=product] input[type=hidden]"); + await search.PressSequentiallyAsync("cordless"); + await Assertions.Expect(page.Locator("[data-picker=product] [role=option]")).ToHaveCountAsync(1); + await search.PressAsync("Enter"); + await Assertions.Expect(choice).ToHaveValueAsync(drill.ToString()); + + await page.EvaluateAsync("Blazor.pauseCircuit()"); + await search.PressSequentiallyAsync("product"); + await page.EvaluateAsync("Blazor.resumeCircuit()"); + await Assertions.Expect(search).ToBeEnabledAsync(); + await Assertions.Expect(choice).ToHaveValueAsync(drill.ToString()); + await Assertions.Expect(page.Locator("[data-testid=picker-choice]")).ToContainTextAsync("Cordless drill"); + + await page.Locator("[data-picker=product] [data-action=clear]").ClickAsync(); + await Assertions.Expect(choice).ToHaveValueAsync(""); + await search.FillAsync(""); + await search.PressSequentiallyAsync("cordless"); + await Assertions.Expect(page.Locator("[data-picker=product] [role=option]")).ToHaveCountAsync(1); + await search.PressAsync("Enter"); + await Assertions.Expect(choice).ToHaveValueAsync(drill.ToString()); + } + + // Enhanced navigation to another version of the entry gives the picker that version's value: starting from the published version + // saves its reference, not the rejected draft's the picker showed before. + [BrowserFact] + public async Task Starting_from_the_published_version_gives_the_picker_its_value() + { + await using var session = await BrowserSession.StartAsync(); + var drill = await ModelAsync(session, products: 1); + var hammer = await session.Host.AsUserAsync(session.OwnerId, async entries => + (await entries.CreateAsync(new NewEntry("product", JsonDocument.Parse("""{"name": "Hammer"}""").RootElement))).Value); + var page = await session.Host.AsUserAsync(session.OwnerId, async entries => (await entries.CreateAsync(new NewEntry( + "product-page", JsonDocument.Parse($$"""{"title": "Published", "product": "{{drill}}"}""").RootElement))).Value); + await session.Host.AsUserAsync(session.OwnerId, async sets => + { + var set = (await sets.OpenAsync(new NewChangeSet("Publish the page.", []))).Value; + await sets.AddOperationAsync(set.Id, ChangeOperation.Create(OperationKind.Create, ItemKind.Entry, page.Id, null, 1).Value); + await sets.TransitionAsync(set.Id, ChangeSetState.ChecksRunning); + await sets.TransitionAsync(set.Id, ChangeSetState.Approved); + return (await sets.PublishAsync(set.Id)).Value; + }); + await session.Host.AsUserAsync(session.OwnerId, async entries => (await entries.UpdateAsync( + page.Id, 1, JsonDocument.Parse($$"""{"title": "Rejected draft", "product": "{{hammer.Id}}"}""").RootElement)).Value); + var browser = session.Page; + + await session.SignInWithKeyboardAsync(); + await browser.GotoAsync($"{session.BaseUrl}/content/product-page/{page.Id}"); + var choice = browser.Locator("[data-picker=product] input[type=hidden]"); + await Assertions.Expect(choice).ToHaveValueAsync(hammer.Id.ToString()); + await browser.GetByTestId("from-published").ClickAsync(); + await Assertions.Expect(browser.Locator("input[name='Form.Values[title]']")).ToHaveValueAsync("Published"); + await Assertions.Expect(choice).ToHaveValueAsync(drill.ToString()); + + await browser.Locator("textarea[name='Form.Rationale']").FillAsync("Back to the published page."); + await browser.Locator("[data-action=save]").ClickAsync(); + await browser.WaitForURLAsync(new Regex("/sets/[0-9a-f-]{36}\\?done=saved$")); + var saved = await session.Host.AsUserAsync(session.OwnerId, async entries => (await entries.GetAsync(page.Id)).Value); + Assert.Equal(drill.ToString(), saved.Latest.Fields.GetProperty("product").GetString()); + + // The same with the circuit lost: the link loads the published version in full, rather than keeping the draft's island. + await session.Host.AsUserAsync(session.OwnerId, async entries => (await entries.UpdateAsync( + page.Id, 3, JsonDocument.Parse($$"""{"title": "Another draft", "product": "{{hammer.Id}}"}""").RootElement)).Value); + await browser.GotoAsync($"{session.BaseUrl}/content/product-page/{page.Id}"); + await Assertions.Expect(choice).ToHaveValueAsync(hammer.Id.ToString()); + await LoseCircuitAsync(browser); + await browser.GetByTestId("from-published").ClickAsync(); + await Assertions.Expect(browser.Locator("input[name='Form.Values[title]']")).ToHaveValueAsync("Published"); + await Assertions.Expect(browser.Locator("[data-picker=product] input[type=hidden]")).ToHaveValueAsync(drill.ToString()); + + // Back after an enhanced move made while the circuit was live, then lost: the draft loads in full, its title and its own choice. + await browser.GotoAsync($"{session.BaseUrl}/content/product-page/{page.Id}"); + await Assertions.Expect(browser.Locator("[data-picker=product] input[type=search]")).ToBeVisibleAsync(); + await browser.GetByTestId("from-published").ClickAsync(); + await Assertions.Expect(browser.Locator("[data-picker=product] input[type=hidden]")).ToHaveValueAsync(drill.ToString()); + await LoseCircuitAsync(browser); + await browser.GoBackAsync(); + await Assertions.Expect(browser.Locator("input[name='Form.Values[title]']")).ToHaveValueAsync("Another draft"); + await Assertions.Expect(browser.Locator("[data-picker=product] input[type=hidden]")).ToHaveValueAsync(hammer.Id.ToString()); + } + + // As when the server no longer knows the circuit: it is paused, and Blazor reports the reconnect refused. + internal static async Task LoseCircuitAsync(IPage page) + { + await page.EvaluateAsync("Blazor.pauseCircuit()"); + await page.EvaluateAsync("document.dispatchEvent(new CustomEvent('components-reconnect-state-changed', { detail: { state: 'rejected' } }))"); + await Assertions.Expect(page.Locator("[data-testid=reconnect-lost]")).ToBeVisibleAsync(); + } + + // 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(); + var drill = await ModelAsync(session, products: 1); + var page = session.Page; + + 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 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 LoseCircuitAsync(page); + + await Assertions.Expect(page.Locator("input[name='Form.Values[title]']")).ToHaveValueAsync("Typed before the loss"); + 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 set = await AwaitingReviewAsync(session, "brand", 2); + var next = await AwaitingReviewAsync(session, "store", 1); + + await session.SignInWithKeyboardAsync(); + await page.GotoAsync($"{session.BaseUrl}/sets/{set.Id}?queue=awaiting-me"); + await Assertions.Expect(page.Locator("[data-testid=change-set-items][data-interactive=true]")).ToBeVisibleAsync(); + await page.Locator("[data-action=reject]").ClickAsync(); + await page.Locator("textarea[name='Reject.Reason']").FillAsync("Typed before the loss"); + await LoseCircuitAsync(page); + + await Assertions.Expect(page.Locator("textarea[name='Reject.Reason']")).ToHaveValueAsync("Typed before the loss"); + + // The next set loads in full, with its own items rather than the dead island of this one. + await page.Keyboard.PressAsync("Escape"); + await page.Keyboard.PressAsync("n"); + await page.WaitForURLAsync(new Regex($"/sets/{next.Id}\\?queue=awaiting-me$")); + await Assertions.Expect(page.Locator("h1")).ToContainTextAsync("Add the store types."); + await Assertions.Expect(page.Locator("[data-slot=diff-view-item]")).ToHaveCountAsync(1); + + // From the first set with a live circuit, N moves on by enhanced navigation; a reason typed there and the circuit lost, Back + // loads the first set in full, with its own two items. + await page.GotoAsync($"{session.BaseUrl}/sets/{set.Id}?queue=awaiting-me"); + await Assertions.Expect(page.Locator("[data-testid=change-set-items][data-interactive=true]")).ToBeVisibleAsync(); + await page.Keyboard.PressAsync("n"); + await Assertions.Expect(page.Locator("h1")).ToContainTextAsync("Add the store types."); + await Assertions.Expect(page.Locator("[data-testid=change-set-items][data-interactive=true]")).ToBeVisibleAsync(); + await page.Locator("[data-action=reject]").ClickAsync(); + await page.Locator("textarea[name='Reject.Reason']").FillAsync("Typed on the next set"); + await LoseCircuitAsync(page); + await page.GoBackAsync(); + await Assertions.Expect(page.Locator("h1")).ToContainTextAsync("Add the brand types."); + await Assertions.Expect(page.Locator("[data-slot=diff-view-item]")).ToHaveCountAsync(2); + } + + private static async Task AwaitingReviewAsync(BrowserSession session, string name, int items) + { + var types = new List(); + for (var index = 0; index < items; index++) + { + var slug = $"{name}-{index}"; + types.Add(await session.Host.AsUserAsync(session.OwnerId, async directory => + (await directory.CreateAsync(new NewContentType(slug, slug, new ContentTypeDefinition([new FieldDefinition("name", "text")])), TestChangeSetTargets.ContentModel)).Value)); + } + + return await session.Host.AsUserAsync(session.OwnerId, async sets => + { + var opened = (await sets.OpenAsync(new NewChangeSet($"Add the {name} types.", []))).Value; + foreach (var type in types) + { + 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; + }); + } + + 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 +340,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.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] 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(); 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"))); }