diff --git a/src/FluentCMS.Admin/Components/Forms/FieldWidget.razor b/src/FluentCMS.Admin/Components/Forms/FieldWidget.razor index 3456566a9..74d2db178 100644 --- a/src/FluentCMS.Admin/Components/Forms/FieldWidget.razor +++ b/src/FluentCMS.Admin/Components/Forms/FieldWidget.razor @@ -3,6 +3,20 @@ @inject IStringLocalizer L @* 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; +} @switch (Field.Type) { @@ -33,16 +47,6 @@ } break; - case "reference": - case "media": - - break; default: break; diff --git a/src/FluentCMS.Admin/Components/Forms/PickerField.razor b/src/FluentCMS.Admin/Components/Forms/PickerField.razor new file mode 100644 index 000000000..1acd12c78 --- /dev/null +++ b/src/FluentCMS.Admin/Components/Forms/PickerField.razor @@ -0,0 +1,201 @@ +@using System.Text.Json +@using FluentCMS.Admin.Forms +@using FluentCMS.Core.Content +@using FluentCMS.Core.Media +@using FluentCMS.Core.Search +@inject ISearch Search +@inject IEntryDirectory Entries +@inject IMediaLibrary Library +@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. *@ + + @if (!RendererInfo.IsInteractive) + { + + } + else + { +
+ +
+ @if (_value is null) + { + @L["Nothing chosen."] + } + else + { + @(_chosenLabel ?? _value) + + } +
+ + @if (_hits.Count > 0) + { +
+ @foreach (var (hit, index) in _hits.Select((hit, index) => (hit, index))) + { + + } +
+ } + else if (_searched) + { +

@L["Nothing matches."]

+ } +
+ } +
+ +@code { + public const string EntryKind = "entry"; + public const string MediaKind = "media"; + private const int MaxHits = 10; + + private readonly string _id = "picker-" + Guid.NewGuid().ToString("N")[..8]; + private string? _value; + private string? _chosenLabel; + private string _text = string.Empty; + private bool _searched; + private List _hits = []; + + [Parameter, EditorRequired] public string Field { get; set; } = string.Empty; + [Parameter, EditorRequired] public string Kind { get; set; } = EntryKind; + [Parameter] public string? Target { get; set; } + [Parameter] public string? Value { get; set; } + [Parameter] public string Label { get; set; } = string.Empty; + [Parameter] public string? Description { get; set; } + [Parameter] public string? Error { get; set; } + [Parameter] public bool Required { get; set; } + // An array, not an interface: parameters of an interactive island cross into the circuit as JSON, which an interface type cannot. + [Parameter] public FieldOption[] Options { get; set; } = []; + + private string Name => $"Form.Values[{Field}]"; + + private string ListId => _id + "-list"; + + protected override void OnParametersSet() + { + _value = string.IsNullOrWhiteSpace(Value) ? null : Value; + _chosenLabel = Options.FirstOrDefault(option => option.Value == _value)?.Label; + } + + private async Task SearchAsync(ChangeEventArgs change) + { + _text = change.Value?.ToString() ?? string.Empty; + _hits = _text.Trim().Length == 0 ? [] : Kind == MediaKind ? await MediaAsync(_text.Trim()) : await EntriesAsync(_text.Trim()); + _searched = _text.Trim().Length > 0; + } + + private async Task> EntriesAsync(string text) + { + if (Target is null) + { + return []; + } + + var found = await Search.SearchAsync(new SearchQuery(text) { ContentTypes = [Target], Limit = MaxHits }); + if (!found.IsSuccess) + { + return []; + } + + var hits = new List(); + foreach (var hit in found.Value.Hits.Where(hit => hit.Via is null).DistinctBy(hit => hit.EntryId)) + { + var entry = await Entries.GetAsync(hit.EntryId); + var name = entry.IsSuccess ? FirstText(entry.Value.Latest.Fields) : null; + hits.Add(new PickerHit(hit.EntryId.ToString(), L["{0} ({1}, {2})", name ?? hit.ContentType, StateLabel(hit.State), hit.EntryId.ToString()[..8]], null)); + } + + return hits; + } + + private async Task> MediaAsync(string text) + { + var assets = await Library.ListAsync(new MediaQuery()); + return + [ + .. assets + .Where(asset => asset.FileName.Contains(text, StringComparison.CurrentCultureIgnoreCase) || asset.Folder.Contains(text, StringComparison.OrdinalIgnoreCase)) + .Reverse() + .Take(MaxHits) + .Select(asset => new PickerHit( + asset.Id.ToString(), + asset.Folder.Length == 0 ? asset.FileName : $"{asset.FileName} ({asset.Folder})", + asset.ContentType.StartsWith("image/", StringComparison.Ordinal) ? AdminRoutes.MediaFile(asset.Id) : null)), + ]; + } + + private void Choose(PickerHit hit) + { + (_value, _chosenLabel) = (hit.Value, hit.Label); + (_hits, _text, _searched) = ([], string.Empty, false); + } + + private void Clear() + { + (_value, _chosenLabel) = (null, null); + } + + private void OnKeyDown(KeyboardEventArgs key) + { + if (key.Key == "Escape") + { + (_hits, _searched) = ([], false); + } + } + + // What names an entry for a person: its first text value, or an entity's first text fact. + private static string? FirstText(JsonElement fields) + { + foreach (var property in fields.EnumerateObject()) + { + var value = property.Value.ValueKind == JsonValueKind.Object && property.Value.TryGetProperty("value", out var fact) ? fact : property.Value; + if (value.ValueKind == JsonValueKind.String && !Guid.TryParse(value.GetString(), out _)) + { + return value.GetString(); + } + } + + return null; + } + + private string StateLabel(EntryState state) => state switch + { + EntryState.Draft => L["draft"], + EntryState.InReview => L["in review"], + EntryState.Published => L["published"], + EntryState.Reverted => L["reverted"], + _ => L["archived"], + }; + + private sealed record PickerHit(string Value, string Label, string? Preview); +} diff --git a/src/FluentCMS.Admin/wwwroot/js/admin.js b/src/FluentCMS.Admin/wwwroot/js/admin.js index 39440baa9..3e1d7be1a 100644 --- a/src/FluentCMS.Admin/wwwroot/js/admin.js +++ b/src/FluentCMS.Admin/wwwroot/js/admin.js @@ -409,6 +409,16 @@ function onRoveKeydown(event) { return true; } + // From a search box that controls a list, ArrowDown enters the list at its first item. + if (target.matches('[data-ui="rove-input"]') && event.key === "ArrowDown") { + const list = document.getElementById(target.getAttribute("aria-controls")); + const items = list ? roveItems(list) : []; + if (items.length === 0) return false; + event.preventDefault(); + rove(items, 0); + return true; + } + if (!inside || !target.closest('[data-ui="rove-item"]')) return false; const items = roveItems(inside); const next = roveIndex(event, items.indexOf(target.closest('[data-ui="rove-item"]')), items.length, "vertical"); diff --git a/tests/FluentCMS.IntegrationTests/Browser/FormPickerTests.cs b/tests/FluentCMS.IntegrationTests/Browser/FormPickerTests.cs new file mode 100644 index 000000000..fd7443337 --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Browser/FormPickerTests.cs @@ -0,0 +1,67 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Content; +using FluentCMS.Core.Schema; +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. + [BrowserFact] + public async Task A_person_picks_a_referenced_entry_by_search_with_the_keyboard_and_saves() + { + await using var session = await BrowserSession.StartAsync(); + var drill = await ModelAsync(session); + 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 Assertions.Expect(search).ToBeVisibleAsync(); + + await page.Locator("input[name='Form.Values[title]']").FocusAsync(); + await page.Keyboard.TypeAsync("Drills"); + await page.Keyboard.PressAsync("Tab"); + await Assertions.Expect(search).ToBeFocusedAsync(); + await page.Keyboard.TypeAsync("cordless"); + 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(); + await page.Keyboard.PressAsync("Enter"); + await Assertions.Expect(page.Locator("[data-picker=product] input[type=hidden]")).ToHaveValueAsync(drill.ToString()); + + await page.Locator("textarea[name='Form.Rationale']").FocusAsync(); + await page.Keyboard.TypeAsync("Add the drills page."); + await page.Keyboard.PressAsync("Tab"); + await page.Keyboard.PressAsync("Tab"); + await page.Keyboard.PressAsync("Enter"); + 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(drill.ToString(), entry.Latest.Fields.GetProperty("product").GetString()); + } + + private static async Task ModelAsync(BrowserSession session) + { + await session.Host.AsUserAsync(session.OwnerId, async types => (await types.CreateAsync(new NewContentType( + "Product", "product", new ContentTypeDefinition([new FieldDefinition("name", "text", required: true)])))).Value); + await session.Host.AsUserAsync(session.OwnerId, async types => (await types.CreateAsync(new NewContentType( + "Product page", + "product-page", + new ContentTypeDefinition( + [ + new FieldDefinition("title", "text", required: true), + new FieldDefinition("product", "reference", settings: JsonDocument.Parse("""{"target": "product"}""").RootElement), + ])))).Value); + await session.Host.AsUserAsync(session.OwnerId, async entries => + (await entries.CreateAsync(new NewEntry("product", JsonDocument.Parse("""{"name": "Hammer"}""").RootElement))).Value); + var drill = await session.Host.AsUserAsync(session.OwnerId, async entries => + (await entries.CreateAsync(new NewEntry("product", JsonDocument.Parse("""{"name": "Cordless drill"}""").RootElement))).Value); + return drill.Id; + } +} diff --git a/tests/FluentCMS.Tests/Admin/GeneratedFormTests.cs b/tests/FluentCMS.Tests/Admin/GeneratedFormTests.cs index d31cf4362..a6fc7da7c 100644 --- a/tests/FluentCMS.Tests/Admin/GeneratedFormTests.cs +++ b/tests/FluentCMS.Tests/Admin/GeneratedFormTests.cs @@ -75,7 +75,8 @@ public void A_stored_value_shows_as_its_widget_takes_it(string type, string sett [InlineData("json", "textarea")] public void Each_field_type_renders_its_widget_inside_a_form_field(string type, string selector) { - using var context = new AdminTestContext(); + // Before the circuit attaches, a reference or media picker is its plain select. + using var context = PickerFieldTests.Context(interactive: false); var cut = context.Render(widget => widget .Add(w => w.Field, Field(type, type == "enum" ? """{"values": ["red", "blue"]}""" : "{}", required: true)) diff --git a/tests/FluentCMS.Tests/Admin/PickerFieldTests.cs b/tests/FluentCMS.Tests/Admin/PickerFieldTests.cs new file mode 100644 index 000000000..d9fb82d69 --- /dev/null +++ b/tests/FluentCMS.Tests/Admin/PickerFieldTests.cs @@ -0,0 +1,176 @@ +using System.Text.Json; +using Bunit; +using FluentCMS.Admin.Components.Forms; +using FluentCMS.Admin.Forms; +using FluentCMS.Core.Content; +using FluentCMS.Core.Media; +using FluentCMS.Core.Results; +using FluentCMS.Core.Search; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.Tests.Admin; + +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"); + + [Fact] + public void Before_the_circuit_attaches_a_picker_is_the_plain_select() + { + using var context = Context(interactive: false); + + var cut = context.Render(EntryPicker()); + + Assert.Equal("Form.Values[product]", cut.Find("select").GetAttribute("name")); + Assert.True(cut.Find($"option[value='{Drill}']").HasAttribute("selected")); + Assert.Empty(cut.FindAll("input[type=search]")); + } + + [Fact] + public void An_entry_picker_searches_the_target_type_and_posts_the_chosen_id() + { + using var context = Context(interactive: true); + var search = (FakeSearch)context.Services.GetRequiredService(); + + var cut = context.Render(EntryPicker(chosen: false)); + Assert.Equal("", cut.Find("input[type=hidden]").GetAttribute("value") ?? ""); + cut.Find("input[type=search]").Input("dri"); + + Assert.Equal(("dri", "product"), (search.Queries[0].Text, search.Queries[0].ContentTypes!.Single())); + var option = cut.Find("[role=option]"); + Assert.Contains("Cordless drill", option.TextContent); + Assert.Equal("rove", cut.Find("[role=listbox]").GetAttribute("data-ui")); + Assert.Equal(cut.Find("[role=listbox]").Id, cut.Find("input[type=search]").GetAttribute("aria-controls")); + + option.Click(); + + Assert.Equal(Drill.ToString(), cut.Find("input[type=hidden]").GetAttribute("value")); + Assert.Contains("Cordless drill", cut.Find("[data-testid=picker-choice]").TextContent); + Assert.Empty(cut.FindAll("[role=option]")); + + cut.Find("[data-action=clear]").Click(); + Assert.Null(cut.Find("input[type=hidden]").GetAttribute("value")); + } + + [Fact] + public void Escape_clears_the_matches_and_a_search_without_matches_says_so() + { + using var context = Context(interactive: true); + + var cut = context.Render(EntryPicker()); + cut.Find("input[type=search]").Input("saw"); + Assert.Contains("Nothing matches.", cut.Markup); + + cut.Find("input[type=search]").Input("dri"); + cut.Find("input[type=search]").KeyDown(new Microsoft.AspNetCore.Components.Web.KeyboardEventArgs { Key = "Escape" }); + Assert.Empty(cut.FindAll("[role=option]")); + } + + [Fact] + public void A_media_picker_finds_assets_by_file_name_with_a_preview() + { + using var context = Context(interactive: true); + + var cut = context.Render(parameters => parameters + .Add(p => p.Field, "hero") + .Add(p => p.Kind, PickerField.MediaKind) + .Add(p => p.Label, "hero")); + cut.Find("input[type=search]").Input("HERO"); + + var option = cut.Find("[role=option]"); + Assert.Contains("hero.jpg (products)", option.TextContent); + Assert.Equal($"media/{Hero}/file", option.QuerySelector("img")!.GetAttribute("src")); + option.Click(); + Assert.Equal(Hero.ToString(), cut.Find("input[type=hidden]").GetAttribute("value")); + } + + internal static AdminTestContext Context(bool interactive) + { + var context = new AdminTestContext(); + AddServices(context); + context.Renderer.SetRendererInfo(new RendererInfo(interactive ? "Server" : "Static", interactive)); + return context; + } + + internal static void AddServices(AdminTestContext context) + { + context.Services.AddSingleton(new FakeSearch()); + context.Services.AddSingleton(new FakeEntries()); + context.Services.AddSingleton(new FakeMedia()); + } + + private static Action> EntryPicker(bool chosen = true) + { + return parameters => parameters + .Add(p => p.Field, "product") + .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")]); + } + + private sealed class FakeSearch : ISearch + { + public List Queries { get; } = []; + + public Task> SearchAsync(SearchQuery query, CancellationToken cancellationToken = default) + { + Queries.Add(query); + IReadOnlyList hits = query.Text == "dri" ? [new SearchHit(Drill, "product", EntryState.Published, 2, 2, 1.0, null)] : []; + return Task.FromResult(Result.Success(new SearchResults(hits))); + } + } + + private sealed class FakeEntries : IEntryDirectory + { + public Task> GetAsync(Guid id, CancellationToken cancellationToken = default) + { + var fields = JsonDocument.Parse("""{"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> GetVersionAsync(Guid id, int version, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task>> HistoryAsync(Guid id, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task> CreateAsync(NewEntry entry, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task> UpdateAsync(Guid id, int baseVersion, JsonElement fields, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task DeleteAsync(Guid id, int baseVersion, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task> RestoreAsync(Guid id, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task> ListDeletedAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); + } + + private sealed class FakeMedia : IMediaLibrary + { + public Task> ListAsync(MediaQuery query, CancellationToken cancellationToken = default) + { + IReadOnlyList 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); + } + + public Task> UploadAsync(NewAsset asset, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task> UpdateAsync(Guid id, int baseVersion, AssetMetadata metadata, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task> GetAsync(Guid id, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task> GetVersionAsync(Guid id, int version, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task> FoldersAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task> OpenAsync(Guid id, int? version = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + } +}