Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/FluentCMS.Admin/Components/Forms/FieldWidget.razor
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@
@* 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")
{
<PickerField Field="@Field.Name"
@* Keyed by field, so each picker of a form keeps its own state with the circuit. *@
<PickerField @key="Field.Name"
Field="@Field.Name"
Kind="@(Field.Type == "media" ? PickerField.MediaKind : PickerField.EntryKind)"
Target="@FieldValues.Setting(Field, "target")"
Value="@Value"
Label="@Label"
Description="@Description"
Error="@Error"
Required="@Field.Required"
Options="@Options.ToArray()"
@rendermode="InteractiveServer" />
return;
}
Expand Down Expand Up @@ -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<FieldOption> Options { get; set; } = [];

private string Name => $"Form.Values[{Field.Name}]";

Expand Down
244 changes: 186 additions & 58 deletions src/FluentCMS.Admin/Components/Forms/PickerField.razor

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions src/FluentCMS.Admin/Components/Layout/ReconnectModal.razor
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
<p>@L["The session has been paused."]</p>
<Button Size="sm" data-reconnect="resume">@L["Resume"]</Button>
</div>
<div data-reconnect-when="lost" class="space-y-3" data-testid="reconnect-lost">
<p>@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."]</p>
<Button Size="sm" Variant="outline" data-reconnect="reload">@L["Reload"]</Button>
</div>
<div data-reconnect-when="resume-failed" class="space-y-3">
<p>@L["Could not resume the session."]</p>
<Button Size="sm" data-reconnect="reload">@L["Reload"]</Button>
Expand Down
22 changes: 2 additions & 20 deletions src/FluentCMS.Admin/Components/Pages/EntryEditor.razor
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -80,7 +78,7 @@
<CardContent Class="grid gap-6 sm:grid-cols-2">
@foreach (var field in type.Definition.Fields)
{
<FieldWidget Field="field" Value="@Shown.Values.GetValueOrDefault(field.Name)" Error="@_fieldErrors.GetValueOrDefault(field.Name)" Options="@_options.GetValueOrDefault(field.Name, [])" />
<FieldWidget Field="field" Value="@Shown.Values.GetValueOrDefault(field.Name)" Error="@_fieldErrors.GetValueOrDefault(field.Name)" />
@if (type.Definition.IsEntity)
{
<FactFields Field="@field.Name" Values="Shown.Facts" Problems="@_factProblems.GetValueOrDefault(field.Name, new Dictionary<string, string>())" Owners="@OwnersFor(field.Name)" Unsourced="@_unsourced.Contains(field.Name)" />
Expand Down Expand Up @@ -111,7 +109,6 @@
@code {
private ContentType? _type;
private Entry? _entry;
private Dictionary<string, IReadOnlyList<FieldOption>> _options = new(StringComparer.Ordinal);
private Dictionary<string, string> _fieldErrors = new(StringComparer.Ordinal);
private Dictionary<string, Dictionary<string, string>> _factProblems = new(StringComparer.Ordinal);
private HashSet<string> _unsourced = new(StringComparer.Ordinal);
Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand Down
5 changes: 3 additions & 2 deletions src/FluentCMS.Admin/Styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand All @@ -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;
}
}
Expand Down
74 changes: 65 additions & 9 deletions src/FluentCMS.Admin/wwwroot/js/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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.
Expand All @@ -445,21 +471,48 @@ 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");
modal?.classList.remove(...reconnectStates);
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);
Expand All @@ -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");
}
Expand All @@ -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);
}

Expand Down Expand Up @@ -510,6 +563,8 @@ document.addEventListener("click", (event) => {
const reconnect = event.target.closest?.("[data-reconnect]");
if (reconnect) onReconnectClick(reconnect);

onPickerClick(event);

closeOnBackdropClick(event);
});

Expand All @@ -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;

Expand Down
9 changes: 8 additions & 1 deletion src/FluentCMS.Core/Media/IMediaLibrary.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
22 changes: 19 additions & 3 deletions src/FluentCMS.Infrastructure/Media/MediaLibrary.cs
Original file line number Diff line number Diff line change
Expand Up @@ -202,11 +202,27 @@ public async Task<IReadOnlyList<MediaAssetSummary>> 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
Expand Down
5 changes: 3 additions & 2 deletions tests/FluentCMS.IntegrationTests/Browser/BrowserSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<BrowserSession> StartAsync()
// Settings such as a TemporaryDatabase's configuration run the host on another provider.
public static async Task<BrowserSession> StartAsync(IDictionary<string, string?>? 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 });
Expand Down
Loading
Loading