+ @ReviewHint(set.State)
+
+
+ @if (_message is not null)
+ {
+
@_message
+ }
+ @if (_refusal is not null)
+ {
+
@_refusal
+ }
+
+ @if (set.State == ChangeSetState.AwaitingReview)
+ {
+
+ }
+ @if (set.State == ChangeSetState.Approved)
+ {
+
+ }
+ @if (set.State is ChangeSetState.AwaitingReview or ChangeSetState.ChecksFailed)
+ {
+
+ }
+ @if (set.State == ChangeSetState.Published)
+ {
+ @* One click (S3): the revert set it opens is reviewed like any other before anything changes. *@
+
+ }
+
+ @* A form posted from a page shown before the set moved on must still find its handler, which then says the set moved on. *@
+ @if (set.State != ChangeSetState.AwaitingReview)
+ {
+
+ }
+ @if (set.State != ChangeSetState.Approved)
+ {
+
+ }
+ @if (set.State is not (ChangeSetState.AwaitingReview or ChangeSetState.ChecksFailed))
+ {
+
+ }
+ @if (set.State != ChangeSetState.Published)
+ {
+
+ }
+
+
+
@@ -159,40 +243,145 @@
private bool _historyTruncated;
private DateTimeOffset? _openedAt;
private DateTimeOffset? _stateSince;
+ private string? _seen;
+ private string? _message;
+ private string? _refusal;
[Parameter] public Guid Id { get; set; }
+ [SupplyParameterFromForm(FormName = "approve")]
+ private ReviewForm? Approve { get; set; }
+
+ [SupplyParameterFromForm(FormName = "publish")]
+ private ReviewForm? Publish { get; set; }
+
+ [SupplyParameterFromForm(FormName = "reject")]
+ private RejectForm? Reject { get; set; }
+
+ [SupplyParameterFromForm(FormName = "revert")]
+ private ReviewForm? Revert { get; set; }
+
protected override async Task OnInitializedAsync()
+ {
+ if (!await LoadAsync())
+ {
+ Navigation.NotFound();
+ }
+ }
+
+ private async Task LoadAsync()
{
var read = await Directory.GetAsync(Id);
if (!read.IsSuccess)
{
- Navigation.NotFound();
- return;
+ _set = null;
+ return false;
}
_set = read.Value;
+ _history = [];
+ _historyTruncated = false;
+ AuditEvent? opened = null;
+ AuditEvent? lastMove = null;
+ // Every event is read for the last move, which the actions are checked against; the page lists the first ones.
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)
+ if (Moves.Contains(auditEvent.Operation))
{
- _historyTruncated = true;
- break;
+ opened ??= auditEvent.Operation == "change_set.opened" ? auditEvent : null;
+ lastMove = auditEvent;
}
- _history.Add(auditEvent);
+ if (_history.Count < HistoryLimit)
+ {
+ _history.Add(auditEvent);
+ }
+ else
+ {
+ _historyTruncated = true;
+ }
}
}
- 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;
+ _openedAt = opened?.Timestamp;
+ _stateSince = lastMove?.Timestamp;
+ _seen = lastMove?.Id.ToString("N");
+ return true;
+ }
+
+ private Task ApproveAsync()
+ {
+ return ActAsync(Approve, () => Directory.TransitionAsync(Id, ChangeSetState.Approved), L["Approved. Publish the set to make its items live."]);
+ }
+
+ private Task PublishAsync()
+ {
+ return ActAsync(Publish, () => Directory.PublishAsync(Id), L["Published. Its items are live."]);
+ }
+
+ private Task RejectAsync()
+ {
+ return ActAsync(Reject, () => Directory.RejectAsync(Id, Reject?.Reason ?? string.Empty), L["Rejected. The reason is stored with the set."]);
+ }
+
+ private async Task RevertAsync()
+ {
+ var rationale = _set is { } set ? L["Revert change set {0}: {1}", set.Id, set.Rationale].Value : string.Empty;
+ if (await ActAsync(Revert, () => Directory.RevertAsync(Id, rationale), null) is { } opened)
+ {
+ Navigation.NavigateTo($"sets/{opened.Id}");
+ }
+ }
+
+ // A decision is saved by the call itself; the page then shows the set as it is now, whatever the outcome.
+ private async Task ActAsync(ReviewForm? form, Func>> action, string? done)
+ {
+ if (_set is null)
+ {
+ return null;
+ }
+
+ if (form?.Seen is null || form.Seen != _seen)
+ {
+ _refusal = L["The set has moved on since this page was shown. Review it as it is now."];
+ return null;
+ }
+
+ var result = await action();
+ if (result.IsSuccess)
+ {
+ _message = done;
+ }
+ else
+ {
+ // The error code is the resource key; the directory's own message is the text until a resource exists.
+ _refusal = string.Join(" ", result.Errors.Select(error =>
+ {
+ var localized = L[error.Code];
+ return localized.ResourceNotFound ? error.Message : localized.Value;
+ }));
+ }
+
+ await LoadAsync();
+ return result.IsSuccess ? result.Value : null;
}
+ private string ReviewHint(ChangeSetState state) => state switch
+ {
+ ChangeSetState.AwaitingReview => L["Approve the set, or reject it with a reason."],
+ ChangeSetState.ChecksFailed => L["The checks failed. Whoever opened the set can fix and resubmit it, or you can reject it with a reason."],
+ ChangeSetState.Approved => L["The set is approved. Publishing it makes all its items live together."],
+ ChangeSetState.Published => L["The set is live. Reverting it opens a set that undoes it, which is reviewed like any other."],
+ ChangeSetState.Open => L["The set is still open, so it cannot be reviewed yet."],
+ ChangeSetState.ChecksRunning => L["The checks are running, so it cannot be reviewed yet."],
+ ChangeSetState.Rejected => L["The set was rejected. Nothing more can be done with it."],
+ _ => L["The set was reverted. Nothing more can be done with it."],
+ };
+
// 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)
{
diff --git a/src/FluentCMS.Admin/Components/Review/ReviewForms.cs b/src/FluentCMS.Admin/Components/Review/ReviewForms.cs
new file mode 100644
index 000000000..3c0bf7cf4
--- /dev/null
+++ b/src/FluentCMS.Admin/Components/Review/ReviewForms.cs
@@ -0,0 +1,14 @@
+namespace FluentCMS.Admin.Components.Review;
+
+// The posted fields of a review action on the set view. Seen names the last move of the set the page was rendered for, so an
+// action is refused once the set has moved on, rather than applied to a set the reviewer has not seen.
+public class ReviewForm
+{
+ public string? Seen { get; set; }
+}
+
+// The directory checks the reason, so a blank one is refused there (CHG-08).
+public sealed class RejectForm : ReviewForm
+{
+ public string? Reason { get; set; }
+}
diff --git a/tests/FluentCMS.IntegrationTests/Desk/ReviewActionTests.cs b/tests/FluentCMS.IntegrationTests/Desk/ReviewActionTests.cs
new file mode 100644
index 000000000..6296029e0
--- /dev/null
+++ b/tests/FluentCMS.IntegrationTests/Desk/ReviewActionTests.cs
@@ -0,0 +1,201 @@
+using System.Net;
+using System.Text.RegularExpressions;
+using FluentCMS.Core.Audit;
+using FluentCMS.Core.ChangeSets;
+using FluentCMS.Core.Schema;
+using FluentCMS.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace FluentCMS.IntegrationTests.Desk;
+
+public sealed partial class ReviewActionTests
+{
+ [Fact]
+ public async Task A_reviewer_approves_and_then_publishes_a_set_and_each_step_names_them()
+ {
+ using var install = new TemporaryInstall();
+ await using var host = await AdminHost.StartAsync(install);
+ var ownerId = await host.CreateFirstAdministratorAsync();
+ var set = await AwaitingReviewAsync(host, ownerId, "product");
+ await host.SignInAsync(AdminHost.Owner);
+
+ var approved = await ActAsync(host, set.Id, "approve");
+ Assert.Equal(HttpStatusCode.OK, approved.StatusCode);
+ var page = await approved.Content.ReadAsStringAsync();
+ Assert.Contains("data-testid=\"review-message\"", page);
+ Assert.Contains("data-state=\"approved\"", page);
+ Assert.Contains("data-action=\"publish\"", page);
+
+ var published = await (await ActAsync(host, set.Id, "publish")).Content.ReadAsStringAsync();
+ Assert.Contains("data-state=\"published\"", published);
+ Assert.Contains("data-action=\"revert\"", published);
+
+ var moves = await MovesAsync(host, set.Id);
+ Assert.Equal(["change_set.approved", "change_set.published"], moves.TakeLast(2).Select(row => row.Operation));
+ Assert.All(moves.TakeLast(2), row => Assert.Equal((InitiatorKinds.User, ownerId), (row.InitiatorKind, row.InitiatorId)));
+ Assert.Contains($"data-change-set=\"{set.Id:N}\"", await host.Client.GetStringAsync("/?queue=recently-published"));
+ }
+
+ [Fact]
+ public async Task A_rejection_needs_a_reason_which_the_view_then_shows()
+ {
+ using var install = new TemporaryInstall();
+ await using var host = await AdminHost.StartAsync(install);
+ var ownerId = await host.CreateFirstAdministratorAsync();
+ var set = await AwaitingReviewAsync(host, ownerId, "product");
+ await host.SignInAsync(AdminHost.Owner);
+
+ var blank = await (await ActAsync(host, set.Id, "reject", ("Reject.Reason", " "))).Content.ReadAsStringAsync();
+ Assert.Contains("data-testid=\"review-refusal\"", blank);
+ Assert.Contains("data-state=\"awaiting-review\"", blank);
+
+ var rejected = await (await ActAsync(host, set.Id, "reject", ("Reject.Reason", "The prices are from last year."))).Content.ReadAsStringAsync();
+ Assert.Contains("data-state=\"rejected\"", rejected);
+ Assert.Contains("The prices are from last year.", rejected);
+ Assert.DoesNotContain("data-action=", rejected);
+ Assert.Equal(ChangeSetState.Rejected, (await host.AsUserAsync(ownerId, async sets => (await sets.GetAsync(set.Id)).Value)).State);
+ }
+
+ [Fact]
+ public async Task Revert_opens_the_set_that_undoes_a_published_one_in_one_click_and_lands_on_it()
+ {
+ using var install = new TemporaryInstall();
+ await using var host = await AdminHost.StartAsync(install);
+ var ownerId = await host.CreateFirstAdministratorAsync();
+ var set = await AwaitingReviewAsync(host, ownerId, "product");
+ await host.SignInAsync(AdminHost.Owner);
+ await ActAsync(host, set.Id, "approve");
+ await ActAsync(host, set.Id, "publish");
+
+ var reverted = await ActAsync(host, set.Id, "revert");
+
+ Assert.Equal(HttpStatusCode.Redirect, reverted.StatusCode);
+ var location = host.LocationOf(reverted)!;
+ var revertId = Guid.Parse(location["/sets/".Length..]);
+ var page = await host.Client.GetStringAsync(location);
+ Assert.Contains("data-state=\"open\"", page);
+ Assert.Contains($"href=\"sets/{set.Id}\"", page);
+ Assert.Contains($"Revert change set {set.Id}: Add the product type.", page);
+ var revert = await host.AsUserAsync(ownerId, async sets => (await sets.GetAsync(revertId)).Value);
+ Assert.Equal(set.Id, revert.RevertsChangeSetId);
+ }
+
+ [Fact]
+ public async Task An_action_on_a_set_that_moved_on_since_the_page_was_shown_is_refused()
+ {
+ using var install = new TemporaryInstall();
+ await using var host = await AdminHost.StartAsync(install);
+ var ownerId = await host.CreateFirstAdministratorAsync();
+ var set = await AwaitingReviewAsync(host, ownerId, "product");
+ await host.SignInAsync(AdminHost.Owner);
+ var seen = Seen(await host.Client.GetStringAsync($"/sets/{set.Id}"), "Approve");
+
+ // Another reviewer approves it in the meantime.
+ await host.AsUserAsync(ownerId, async sets => (await sets.TransitionAsync(set.Id, ChangeSetState.Approved)).Value);
+
+ var stale = await host.SubmitAsync($"/sets/{set.Id}", "approve", new Dictionary { ["Approve.Seen"] = seen });
+ var page = await stale.Content.ReadAsStringAsync();
+
+ Assert.Contains("data-testid=\"review-refusal\"", page);
+ Assert.Contains("data-state=\"approved\"", page);
+ Assert.Single(await MovesAsync(host, set.Id), row => row.Operation == "change_set.approved");
+ }
+
+ [Fact]
+ public async Task A_post_without_the_anti_forgery_token_changes_nothing_and_an_anonymous_one_goes_to_sign_in()
+ {
+ using var install = new TemporaryInstall();
+ await using var host = await AdminHost.StartAsync(install);
+ var ownerId = await host.CreateFirstAdministratorAsync();
+ var set = await AwaitingReviewAsync(host, ownerId, "product");
+
+ var anonymous = await host.Client.PostAsync($"/sets/{set.Id}", Form(("_handler", "approve")));
+ await host.SignInAsync(AdminHost.Owner);
+ var seen = Seen(await host.Client.GetStringAsync($"/sets/{set.Id}"), "Approve");
+ var forged = await host.Client.PostAsync($"/sets/{set.Id}", Form(("_handler", "approve"), ("Approve.Seen", seen)));
+
+ Assert.Equal(HttpStatusCode.Redirect, anonymous.StatusCode);
+ Assert.StartsWith("/sign-in?", host.LocationOf(anonymous));
+ Assert.Equal(HttpStatusCode.BadRequest, forged.StatusCode);
+ Assert.DoesNotContain(await MovesAsync(host, set.Id), row => row.Operation == "change_set.approved");
+ }
+
+ [Fact]
+ public async Task Only_the_actions_the_state_allows_are_offered()
+ {
+ using var install = new TemporaryInstall();
+ await using var host = await AdminHost.StartAsync(install);
+ var ownerId = await host.CreateFirstAdministratorAsync();
+ var waiting = await AwaitingReviewAsync(host, ownerId, "product");
+ var failed = await OpenAsync(host, ownerId, "category", ChangeSetState.ChecksRunning, ChangeSetState.ChecksFailed);
+ var running = await OpenAsync(host, ownerId, "brand", ChangeSetState.ChecksRunning);
+ await host.SignInAsync(AdminHost.Owner);
+
+ Assert.Equal(["approve", "reject"], Actions(await host.Client.GetStringAsync($"/sets/{waiting.Id}")));
+ Assert.Equal(["reject"], Actions(await host.Client.GetStringAsync($"/sets/{failed.Id}")));
+ Assert.Empty(Actions(await host.Client.GetStringAsync($"/sets/{running.Id}")));
+ }
+
+ private static async Task ActAsync(AdminHost host, Guid id, string action, params (string Name, string Value)[] fields)
+ {
+ var path = $"/sets/{id}";
+ var form = fields.ToDictionary(field => field.Name, field => field.Value);
+ form[$"{char.ToUpperInvariant(action[0])}{action[1..]}.Seen"] = Seen(await host.Client.GetStringAsync(path), action);
+ return await host.SubmitAsync(path, action, form);
+ }
+
+ private static string Seen(string page, string action)
+ {
+ var name = $"{char.ToUpperInvariant(action[0])}{action[1..]}.Seen";
+ var match = Regex.Match(page, $"name=\"{Regex.Escape(name)}\" value=\"([0-9a-f]+)\"");
+ Assert.True(match.Success, $"The page has no {name} field.");
+ return match.Groups[1].Value;
+ }
+
+ private static List Actions(string page)
+ {
+ return [.. ActionAttribute().Matches(page).Select(match => match.Groups[1].Value)];
+ }
+
+ private static FormUrlEncodedContent Form(params (string Name, string Value)[] fields)
+ {
+ return new FormUrlEncodedContent(fields.ToDictionary(field => field.Name, field => field.Value));
+ }
+
+ private static Task AwaitingReviewAsync(AdminHost host, Guid userId, string slug)
+ {
+ return OpenAsync(host, userId, slug, ChangeSetState.ChecksRunning, ChangeSetState.AwaitingReview);
+ }
+
+ private static async Task OpenAsync(AdminHost host, Guid userId, string slug, params ChangeSetState[] moves)
+ {
+ var type = await host.AsUserAsync(userId, async directory =>
+ (await directory.CreateAsync(new NewContentType(slug, slug, new ContentTypeDefinition([new FieldDefinition("title", "text")])))).Value);
+
+ return await host.AsUserAsync(userId, async directory =>
+ {
+ var set = (await directory.OpenAsync(new NewChangeSet($"Add the {slug} type.", []))).Value;
+ set = (await directory.AddOperationAsync(set.Id, ChangeOperation.Create(OperationKind.Create, ItemKind.Schema, type.Id, null, 1).Value)).Value;
+ foreach (var move in moves)
+ {
+ set = (await directory.TransitionAsync(set.Id, move)).Value;
+ }
+
+ return set;
+ });
+ }
+
+ private static async Task> MovesAsync(AdminHost host, Guid id)
+ {
+ await using var scope = host.App.Services.CreateAsyncScope();
+ var rows = await scope.ServiceProvider.GetRequiredService().AuditEvents
+ .AsNoTracking()
+ .Where(row => row.SubjectType == "change_set" && row.SubjectId == id)
+ .ToListAsync();
+ return [.. rows.OrderBy(row => row.Timestamp)];
+ }
+
+ [GeneratedRegex("data-action=\"([a-z]+)\"")]
+ private static partial Regex ActionAttribute();
+}
diff --git a/tests/FluentCMS.Tests/Admin/ChangeSetViewTests.cs b/tests/FluentCMS.Tests/Admin/ChangeSetViewTests.cs
index 915b27557..bc8548aa1 100644
--- a/tests/FluentCMS.Tests/Admin/ChangeSetViewTests.cs
+++ b/tests/FluentCMS.Tests/Admin/ChangeSetViewTests.cs
@@ -106,6 +106,47 @@ public void Says_where_check_results_and_the_routing_decision_will_appear()
Assert.Contains("Routing has not decided on this set.", cut.Find("[data-testid=change-set-routing]").TextContent);
}
+ [Theory]
+ [InlineData(ChangeSetState.AwaitingReview, new[] { "approve", "reject" })]
+ [InlineData(ChangeSetState.ChecksFailed, new[] { "reject" })]
+ [InlineData(ChangeSetState.Approved, new[] { "publish" })]
+ [InlineData(ChangeSetState.Published, new[] { "revert" })]
+ [InlineData(ChangeSetState.Open, new string[0])]
+ [InlineData(ChangeSetState.ChecksRunning, new string[0])]
+ [InlineData(ChangeSetState.Rejected, new string[0])]
+ [InlineData(ChangeSetState.Reverted, new string[0])]
+ public void The_review_panel_offers_only_the_actions_the_state_allows(ChangeSetState state, string[] actions)
+ {
+ var sets = new FakeSets { State = state };
+ using var context = Context(sets);
+
+ var cut = context.Render(page => page.Add(p => p.Id, sets.Set.Id));
+
+ var review = cut.Find("[data-testid=change-set-review]");
+ Assert.Equal(actions, review.QuerySelectorAll("[data-action]").Select(action => action.GetAttribute("data-action")));
+ Assert.NotEmpty(review.QuerySelector("[data-slot=card-description]")!.TextContent.Trim());
+
+ // Every action's form is on the page, so a post from an older page still reaches its handler; only the offered ones show.
+ Assert.Equal(4, review.QuerySelectorAll("form").Length);
+ Assert.Equal(4 - actions.Length, review.QuerySelectorAll("form[hidden]").Length);
+ Assert.All(
+ review.QuerySelectorAll("input[name$='.Seen']"),
+ seen => Assert.Matches("^[0-9a-f]{32}$", seen.GetAttribute("value")));
+ }
+
+ [Fact]
+ public void A_rejection_asks_for_its_reason_in_a_dialog()
+ {
+ var sets = new FakeSets();
+ using var context = Context(sets);
+
+ var cut = context.Render(page => page.Add(p => p.Id, sets.Set.Id));
+
+ var reason = cut.Find("dialog textarea[name='Reject.Reason']");
+ Assert.True(reason.HasAttribute("required"));
+ Assert.Equal("dialog", cut.Find("[data-action=reject]").GetAttribute("aria-haspopup"));
+ }
+
[Fact]
public void An_unknown_set_is_not_found()
{