From 3b5e21838b49e6837c9a838cc4a454d30e81c91b Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 00:21:09 -0400 Subject: [PATCH] Add the set view with the per-item diff rendered progressively --- .../Components/Pages/ChangeSetView.razor | 248 +++++++++++++++++ .../Components/Pages/Desk.razor | 2 +- .../Components/Review/ChangeSetItems.razor | 49 ++++ .../Desk/ChangeSetScreenTests.cs | 124 +++++++++ .../Admin/ChangeSetViewTests.cs | 254 ++++++++++++++++++ tests/FluentCMS.Tests/Admin/DeskTests.cs | 4 +- 6 files changed, 678 insertions(+), 3 deletions(-) create mode 100644 src/FluentCMS.Admin/Components/Pages/ChangeSetView.razor create mode 100644 src/FluentCMS.Admin/Components/Review/ChangeSetItems.razor create mode 100644 tests/FluentCMS.IntegrationTests/Desk/ChangeSetScreenTests.cs create mode 100644 tests/FluentCMS.Tests/Admin/ChangeSetViewTests.cs diff --git a/src/FluentCMS.Admin/Components/Pages/ChangeSetView.razor b/src/FluentCMS.Admin/Components/Pages/ChangeSetView.razor new file mode 100644 index 000000000..7658bc502 --- /dev/null +++ b/src/FluentCMS.Admin/Components/Pages/ChangeSetView.razor @@ -0,0 +1,248 @@ +@page "/sets/{Id:guid}" +@using System.Globalization +@using FluentCMS.Admin.Components.Review +@using FluentCMS.Core.Audit +@using FluentCMS.Core.ChangeSets +@inject IChangeSetDirectory Directory +@inject IAuditReader Audit +@inject NavigationManager Navigation +@inject IStringLocalizer L + +@L["Change set"] + +@if (_set is { } set) +{ +
+

@L["Change set"] @set.Id

+

@set.Rationale

+
+ @StateLabel(set.State) + @if (set.Initiator is AgentInitiator) + { + @L["Agent"] + } + else + { + @L["User"] + } + @set.Initiator.DisplayName + @if (_openedAt is { } openedAt) + { + @L["opened"] @Time(openedAt) + } + @if (_stateSince is { } since && since != _openedAt) + { + @L["in this state since"] @Time(since) + } +
+ @if (set.RevertsChangeSetId is { } reverted) + { +

+ @L["This set reverts"] @reverted +

+ } + @if (set.RejectionReason is { } reason) + { +
+

@L["Rejected because"]

+

@reason

+
+ } +
+ +
+ + +

@L["Sources"]

+ @L["Where the changes came from. A web address opens in a new tab."] +
+ + @if (set.Sources.Count == 0) + { +

@L["This set cites no sources."]

+ } + else + { +
    + @foreach (var source in set.Sources) + { +
  • + @if (Openable(source) is { } address) + { + @source + } + else + { + @source + } +
  • + } +
+ } +
+
+ +
+ + +

@L["Checks"]

+ + @if (set.State == ChangeSetState.ChecksFailed) + { + @L["The checks failed for this set."] + } + else + { + @L["No check results are stored for this set."] + } + @L["Results per check appear here once checks run on every change set."] + +
+
+ + +

@L["Routing"]

+ @L["Routing has not decided on this set. Its decision, with the rules that applied and why, appears here once routing is available."] +
+
+
+
+ +
+

@L["Items"] (@set.Operations.Count.ToString(CultureInfo.CurrentCulture))

+
+ +
+
+ +
+

@L["History"]

+
    + @foreach (var auditEvent in _history) + { +
  1. + @EventLabel(auditEvent.Operation) + @auditEvent.Initiator.DisplayName + @Time(auditEvent.Timestamp) +
  2. + } +
+ @if (_historyTruncated) + { +

+ @L["Only the first {0} events are shown.", HistoryLimit] + @L["See the full history"] +

+ } +
+} + +@code { + private const int HistoryLimit = 200; + + // The events the directory appends when it opens a set and on each move; refusals and other events about the set are history too. + private static readonly HashSet Moves = + [ + "change_set.opened", + "change_set.reopened", + "change_set.submitted", + "change_set.checks_failed", + "change_set.awaiting_review", + "change_set.approved", + "change_set.published", + "change_set.rejected", + "change_set.reverted", + ]; + + private ChangeSet? _set; + private List _history = []; + private bool _historyTruncated; + private DateTimeOffset? _openedAt; + private DateTimeOffset? _stateSince; + + [Parameter] public Guid Id { get; set; } + + protected override async Task OnInitializedAsync() + { + var read = await Directory.GetAsync(Id); + if (!read.IsSuccess) + { + Navigation.NotFound(); + return; + } + + _set = read.Value; + + var history = Audit.Read(new AuditQuery { Subject = new AuditSubject("change_set", Id) }); + if (history.IsSuccess) + { + await foreach (var auditEvent in history.Value) + { + if (_history.Count == HistoryLimit) + { + _historyTruncated = true; + break; + } + + _history.Add(auditEvent); + } + } + + var moves = _history.Where(auditEvent => Moves.Contains(auditEvent.Operation)).ToList(); + _openedAt = moves.FirstOrDefault(auditEvent => auditEvent.Operation == "change_set.opened")?.Timestamp; + _stateSince = _historyTruncated ? null : moves.LastOrDefault()?.Timestamp; + } + + // Only a web address becomes a link, so a source such as "javascript:" or a file path is never followed. + public static string? Openable(string source) + { + return Uri.TryCreate(source, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeHttp) + ? uri.AbsoluteUri + : null; + } + + private static string StateSlug(ChangeSetState state) => state switch + { + ChangeSetState.ChecksRunning => "checks-running", + ChangeSetState.ChecksFailed => "checks-failed", + ChangeSetState.AwaitingReview => "awaiting-review", + _ => state.ToString().ToLowerInvariant(), + }; + + private string StateLabel(ChangeSetState state) => state switch + { + ChangeSetState.Open => L["Open"], + ChangeSetState.ChecksRunning => L["Checks running"], + ChangeSetState.ChecksFailed => L["Checks failed"], + ChangeSetState.AwaitingReview => L["Awaiting review"], + ChangeSetState.Approved => L["Approved"], + ChangeSetState.Published => L["Published"], + ChangeSetState.Rejected => L["Rejected"], + _ => L["Reverted"], + }; + + private static string StateVariant(ChangeSetState state) => state switch + { + ChangeSetState.ChecksFailed or ChangeSetState.Rejected => "destructive", + ChangeSetState.AwaitingReview or ChangeSetState.Approved => "default", + ChangeSetState.Published => "secondary", + _ => "outline", + }; + + private string EventLabel(string operation) => operation switch + { + "change_set.opened" => L["Opened"], + "change_set.reopened" => L["Reopened"], + "change_set.submitted" => L["Submitted for checks"], + "change_set.checks_failed" => L["Checks failed"], + "change_set.awaiting_review" => L["Awaiting review"], + "change_set.approved" => L["Approved"], + "change_set.published" => L["Published"], + "change_set.rejected" => L["Rejected"], + "change_set.reverted" => L["Reverted"], + _ => operation, + }; + + private static RenderFragment Time(DateTimeOffset time) => + @; +} diff --git a/src/FluentCMS.Admin/Components/Pages/Desk.razor b/src/FluentCMS.Admin/Components/Pages/Desk.razor index 8b9a63a42..c9a527bd0 100644 --- a/src/FluentCMS.Admin/Components/Pages/Desk.razor +++ b/src/FluentCMS.Admin/Components/Pages/Desk.razor @@ -127,7 +127,7 @@ { - @set.Rationale + @set.Rationale @if (set.RevertsChangeSetId is not null) { @L["Revert"] diff --git a/src/FluentCMS.Admin/Components/Review/ChangeSetItems.razor b/src/FluentCMS.Admin/Components/Review/ChangeSetItems.razor new file mode 100644 index 000000000..96102f7a4 --- /dev/null +++ b/src/FluentCMS.Admin/Components/Review/ChangeSetItems.razor @@ -0,0 +1,49 @@ +@using FluentCMS.Core.ChangeSets +@inject IChangeSetDirectory Directory +@inject IStringLocalizer L + +@* The set view's interactive island: DiffView adds items in batches over the circuit (PERF-04) while the rest of the page stays static. *@ +
+ @if (_error is not null) + { + + } + else if (_diff is null) + { +

@L["Loading the items…"]

+ } + else + { + + } +
+ +@code { + private ChangeSetDiff? _diff; + private string? _error; + + [Parameter, EditorRequired] public Guid ChangeSetId { get; set; } + + protected override async Task OnParametersSetAsync() + { + if (_diff?.ChangeSetId == ChangeSetId) + { + return; + } + + var diff = await Directory.DiffAsync(ChangeSetId); + if (diff.IsSuccess) + { + (_diff, _error) = (diff.Value, null); + } + else + { + // The error code is the resource key; the directory's own message is the text until a resource exists. + _error = string.Join(" ", diff.Errors.Select(error => + { + var localized = L[error.Code]; + return localized.ResourceNotFound ? error.Message : localized.Value; + })); + } + } +} diff --git a/tests/FluentCMS.IntegrationTests/Desk/ChangeSetScreenTests.cs b/tests/FluentCMS.IntegrationTests/Desk/ChangeSetScreenTests.cs new file mode 100644 index 000000000..6ef5c8755 --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Desk/ChangeSetScreenTests.cs @@ -0,0 +1,124 @@ +using System.Net; +using System.Text.RegularExpressions; +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Schema; + +namespace FluentCMS.IntegrationTests.Desk; + +public sealed class ChangeSetScreenTests +{ + [Fact] + public async Task An_anonymous_request_for_a_set_is_sent_to_sign_in() + { + using var install = new TemporaryInstall(); + await using var host = await AdminHost.StartAsync(install); + await host.CreateFirstAdministratorAsync(); + var id = Guid.NewGuid(); + + var response = await host.Client.GetAsync($"/sets/{id}"); + + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.StartsWith("/sign-in?ReturnUrl=%2Fsets%2F", host.LocationOf(response)); + } + + [Fact] + public async Task An_unknown_set_is_not_found() + { + using var install = new TemporaryInstall(); + await using var host = await AdminHost.StartAsync(install); + await host.CreateFirstAdministratorAsync(); + await host.SignInAsync(AdminHost.Owner); + + var response = await host.Client.GetAsync($"/sets/{Guid.NewGuid()}"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Contains("There is nothing at this address.", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task A_set_shows_its_rationale_sources_history_and_first_items_and_the_desk_links_to_it() + { + using var install = new TemporaryInstall(); + await using var host = await AdminHost.StartAsync(install); + var ownerId = await host.CreateFirstAdministratorAsync(); + var types = new List(); + for (var index = 0; index < 30; index++) + { + types.Add(await CreateTypeAsync(host, ownerId, $"type-{index}")); + } + + var set = await host.AsUserAsync(ownerId, async directory => + { + var opened = (await directory.OpenAsync(new NewChangeSet( + "Add thirty types.", + ["https://example.com/spec", "javascript:alert(1)", "specs/types.md"]))).Value; + foreach (var type in types) + { + Assert.True((await directory.AddOperationAsync(opened.Id, Create(type))).IsSuccess); + } + + await directory.TransitionAsync(opened.Id, ChangeSetState.ChecksRunning); + return (await directory.TransitionAsync(opened.Id, ChangeSetState.AwaitingReview)).Value; + }); + await host.SignInAsync(AdminHost.Owner); + + Assert.Contains($"href=\"sets/{set.Id}\"", await host.Client.GetStringAsync("/")); + var page = await host.Client.GetStringAsync($"/sets/{set.Id}"); + + Assert.Contains("

Add thirty types.

", page); + Assert.Contains("data-state=\"awaiting-review\"", page); + Assert.Contains("href=\"https://example.com/spec\" target=\"_blank\" rel=\"noopener noreferrer\"", page); + Assert.DoesNotContain("href=\"javascript:", page); + Assert.Contains("specs/types.md", page); + Assert.Equal( + ["change_set.opened", "change_set.submitted", "change_set.awaiting_review"], + Regex.Matches(page[page.IndexOf("data-testid=\"change-set-history\"", StringComparison.Ordinal)..], "data-operation=\"([a-z_.]+)\"").Select(match => match.Groups[1].Value)); + + // The island arrives with the page holding the first batch; the circuit adds the rest. + Assert.Equal(25, Regex.Count(page, "data-slot=\"diff-view-item\"")); + Assert.Contains("aria-busy=\"true\"", page); + Assert.Contains("