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
{
+ @* 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. *@
+
@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["Reload"]
+
@L["Could not resume the session."]
@L["Reload"]
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")));
}