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
1 change: 1 addition & 0 deletions src/FluentCMS.Admin/Components/Layout/MainLayout.razor
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<a href="" class="font-semibold">FluentCMS</a>
@if (Initiator.Current is UserInitiator user)
{
<a href="" class="text-sm text-muted-foreground hover:text-foreground">@L["Desk"]</a>
<a href="audit" class="text-sm text-muted-foreground hover:text-foreground">@L["Audit log"]</a>
<a href="users" class="text-sm text-muted-foreground hover:text-foreground">@L["Users"]</a>
<a href="agents" class="text-sm text-muted-foreground hover:text-foreground">@L["Agents"]</a>
Expand Down
317 changes: 317 additions & 0 deletions src/FluentCMS.Admin/Components/Pages/Desk.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,317 @@
@page "/"
@using System.Globalization
@using FluentCMS.Core.ChangeSets
@using FluentCMS.Core.Desk
@using FluentCMS.Core.Entities
@inject IDeskQueues Queues
@inject IStringLocalizer<Desk> L

<PageTitle>@L["Desk"]</PageTitle>

<h1 class="text-2xl font-semibold tracking-tight">@L["Desk"]</h1>
<p class="mt-1 text-sm text-muted-foreground">@L["Change sets and facts that are waiting for a person."]</p>

<nav class="mt-6" aria-label="@L["Queues"]" data-testid="desk-queues">
<ul class="flex flex-wrap gap-1">
@foreach (var queue in Order)
{
var current = queue == _queue;
<li>
<a href="@Href(queue, 1)"
aria-current="@(current ? "page" : null)"
data-queue="@Slugs[queue]"
class="@ButtonVariants.Classes(current ? "secondary" : "ghost", "sm")">
@Title(queue)
<Badge Variant="@(current ? "default" : "outline")" data-count="@Count(queue)">@Count(queue)</Badge>
</a>
</li>
}
</ul>
</nav>
@if (_countsError is not null)
{
<p role="alert" class="mt-2 text-sm text-destructive" data-testid="desk-counts-error">@L["The counts could not be read."] @_countsError</p>
}

<section class="mt-6" aria-labelledby="queue-heading" data-testid="desk-queue" data-current-queue="@Slugs[_queue]">
<h2 id="queue-heading" class="text-lg font-semibold">@Title(_queue)</h2>
<p class="mt-1 text-sm text-muted-foreground">@Description(_queue)</p>
@if (_queue == DeskQueue.AwaitingMe)
{
<p class="mt-2 text-sm text-muted-foreground" data-testid="awaiting-me-note">
@L["Until routing records approvals, this queue lists every set awaiting review, as awaiting anyone does."]
</p>
}

@if (_error is not null)
{
<p role="alert" class="mt-4 text-sm text-destructive" data-testid="desk-error">@_error</p>
}
else if (_total == 0)
{
<p class="mt-4 rounded-md border px-3 py-6 text-center text-sm text-muted-foreground" data-testid="desk-empty">@Empty(_queue)</p>
}
else if (_shown == 0)
{
<p class="mt-4 rounded-md border px-3 py-6 text-center text-sm text-muted-foreground" data-testid="desk-past-end">
@L["This page is past the end of the queue."]
<a href="@Href(_queue, 1)" class="underline underline-offset-4">@L["Go to the first page"]</a>
</p>
}
else
{
<div class="mt-4 rounded-md border">
@if (_facts is not null)
{
<Table aria-labelledby="queue-heading">
<TableHeader>
<TableRow>
<TableHead>@L["Entity"]</TableHead>
<TableHead>@L["Field"]</TableHead>
<TableHead>@L["Reason"]</TableHead>
<TableHead>@L["Stale since"]</TableHead>
<TableHead>@L["Held by"]</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@foreach (var fact in _facts)
{
<TableRow data-fact="@($"{fact.EntityId:N}/{fact.Field}/{fact.Version}")">
<TableCell>
<a href="@($"audit?type=entity&id={fact.EntityId}")" class="font-medium underline-offset-4 hover:underline">
<bdi>@fact.Type</bdi> <bdi class="text-muted-foreground" dir="ltr">@fact.EntityId.ToString("N")[..8]</bdi>
</a>
</TableCell>
<TableCell><bdi>@fact.Field</bdi></TableCell>
<TableCell>
@if (fact.Reason == StaleReason.Expired)
{
<Badge Variant="destructive" data-reason="expired">@L["Expired"]</Badge>
}
else
{
<Badge Variant="secondary" data-reason="unverified">@L["Unverified"]</Badge>
}
</TableCell>
<TableCell>@Time(fact.StaleSince)</TableCell>
<TableCell>
<span class="flex gap-1">
@if (fact.IsPublished)
{
<Badge Variant="outline">@L["Published"]</Badge>
}
@if (fact.IsLatest)
{
<Badge Variant="outline">@L["Latest draft"]</Badge>
}
</span>
</TableCell>
</TableRow>
}
</TableBody>
</Table>
}
else
{
<Table aria-labelledby="queue-heading">
<TableHeader>
<TableRow>
<TableHead>@L["Change set"]</TableHead>
<TableHead>@L["Started by"]</TableHead>
<TableHead Class="text-end">@L["Items"]</TableHead>
<TableHead>@Since(_queue)</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@foreach (var set in _sets ?? [])
{
<TableRow data-change-set="@set.Id.ToString("N")">
<TableCell Class="whitespace-normal">
<a href="@($"audit?type=change_set&id={set.Id}")" class="font-medium underline-offset-4 hover:underline">@set.Rationale</a>
@if (set.RevertsChangeSetId is not null)
{
<Badge Variant="outline" Class="ms-2" data-reverts="@set.RevertsChangeSetId.Value.ToString("N")">@L["Revert"]</Badge>
}
</TableCell>
<TableCell>
<span class="flex items-center gap-2">
@if (set.Initiator is AgentInitiator)
{
<Badge data-initiator="agent">@L["Agent"]</Badge>
}
else
{
<Badge Variant="secondary" data-initiator="user">@L["User"]</Badge>
}
<bdi>@set.Initiator.DisplayName</bdi>
</span>
</TableCell>
<TableCell Class="text-end tabular-nums">@set.ItemCount.ToString(CultureInfo.CurrentCulture)</TableCell>
<TableCell>@Time(set.StateSince)</TableCell>
</TableRow>
}
</TableBody>
</Table>
}
</div>

<nav class="mt-4 flex flex-wrap items-center justify-between gap-4" aria-label="@L["Pages"]" data-testid="desk-pages">
<p class="text-sm text-muted-foreground">@L["{0} to {1} of {2}", _offset + 1, _offset + _shown, _total]</p>
<span class="flex gap-2">
@if (_page > 1)
{
<a href="@Href(_queue, _page - 1)" rel="prev" class="@ButtonVariants.Classes("outline", "sm")">@L["Previous"]</a>
}
@if (_offset + PageSize < _total)
{
<a href="@Href(_queue, _page + 1)" rel="next" class="@ButtonVariants.Classes("outline", "sm")">@L["Next"]</a>
}
</span>
</nav>
}
</section>

@code {
public const int PageSize = 50;

private static readonly DeskQueue[] Order =
[
DeskQueue.AwaitingMe,
DeskQueue.AwaitingAnyone,
DeskQueue.ChecksFailed,
DeskQueue.StaleFacts,
DeskQueue.RecentlyPublished,
DeskQueue.Reverted,
];

// Each queue has its own URL, so a queue and a page of it can be bookmarked and shared.
public static readonly IReadOnlyDictionary<DeskQueue, string> Slugs = new Dictionary<DeskQueue, string>
{
[DeskQueue.AwaitingMe] = "awaiting-me",
[DeskQueue.AwaitingAnyone] = "awaiting-anyone",
[DeskQueue.ChecksFailed] = "checks-failed",
[DeskQueue.StaleFacts] = "stale-facts",
[DeskQueue.RecentlyPublished] = "recently-published",
[DeskQueue.Reverted] = "reverted",
};

private DeskQueue _queue;
private int _page;
private int _offset;
private int _total;
private int _shown;
private IReadOnlyDictionary<DeskQueue, int> _counts = new Dictionary<DeskQueue, int>();
private IReadOnlyList<ChangeSetSummary>? _sets;
private IReadOnlyList<StaleFact>? _facts;
private string? _error;
private string? _countsError;

[SupplyParameterFromQuery(Name = "queue")]
private string? QueueSlug { get; set; }

[SupplyParameterFromQuery(Name = "page")]
private int? PageNumber { get; set; }

protected override async Task OnInitializedAsync()
{
// An unknown queue shows the default one rather than an error, as a mistyped link would otherwise land nowhere.
_queue = Slugs.FirstOrDefault(pair => pair.Value == QueueSlug).Key;
_page = Math.Max(1, PageNumber ?? 1);
_offset = (int)Math.Min((_page - 1L) * PageSize, int.MaxValue - PageSize);

var counts = await Queues.CountAsync();
if (counts.IsSuccess)
{
_counts = counts.Value;
}
else
{
_countsError = Describe(counts.Errors);
}

if (_queue == DeskQueue.StaleFacts)
{
var facts = await Queues.ListStaleFactsAsync(_offset, PageSize);
if (facts.IsSuccess)
{
(_facts, _total, _shown) = (facts.Value.Items, facts.Value.Total, facts.Value.Items.Count);
}
else
{
_error = Describe(facts.Errors);
}
}
else
{
var sets = await Queues.ListChangeSetsAsync(_queue, _offset, PageSize);
if (sets.IsSuccess)
{
(_sets, _total, _shown) = (sets.Value.Items, sets.Value.Total, sets.Value.Items.Count);
}
else
{
_error = Describe(sets.Errors);
}
}
}

private static string Href(DeskQueue queue, int page)
{
return page > 1 ? $"?queue={Slugs[queue]}&page={page}" : $"?queue={Slugs[queue]}";
}

private string Count(DeskQueue queue)
{
return _counts.TryGetValue(queue, out var count) ? count.ToString(CultureInfo.CurrentCulture) : "–";
}

private string Title(DeskQueue queue) => queue switch
{
DeskQueue.AwaitingMe => L["Awaiting me"],
DeskQueue.AwaitingAnyone => L["Awaiting anyone"],
DeskQueue.ChecksFailed => L["Checks failed"],
DeskQueue.StaleFacts => L["Stale facts"],
DeskQueue.RecentlyPublished => L["Recently published"],
_ => L["Reverted"],
};

private string Description(DeskQueue queue) => queue switch
{
DeskQueue.AwaitingMe => L["Change sets waiting for your review, the longest waiting first."],
DeskQueue.AwaitingAnyone => L["Change sets waiting for any reviewer, the longest waiting first."],
DeskQueue.ChecksFailed => L["Change sets whose checks failed, the longest waiting first. The agent or person who opened one fixes and resubmits it, or a reviewer rejects it."],
DeskQueue.StaleFacts => L["Facts past their valid-until date or not verified recently, the longest stale first."],
DeskQueue.RecentlyPublished => L["Change sets published in the last seven days, the newest first."],
_ => L["Change sets reverted in the last seven days, the newest first."],
};

private string Empty(DeskQueue queue) => queue switch
{
DeskQueue.AwaitingMe or DeskQueue.AwaitingAnyone => L["Nothing is awaiting review."],
DeskQueue.ChecksFailed => L["No change set has failed its checks."],
DeskQueue.StaleFacts => L["No fact is stale."],
DeskQueue.RecentlyPublished => L["Nothing was published in the last seven days."],
_ => L["Nothing was reverted in the last seven days."],
};

private string Since(DeskQueue queue) => queue switch
{
DeskQueue.ChecksFailed => L["Failed"],
DeskQueue.RecentlyPublished => L["Published"],
DeskQueue.Reverted => L["Reverted"],
_ => L["Waiting since"],
};

private static RenderFragment Time(DateTimeOffset time) =>
@<time datetime="@time.ToString("O", CultureInfo.InvariantCulture)" dir="ltr" class="text-muted-foreground">@time.UtcDateTime.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) UTC</time>;

// The error code is the resource key; the service's own message is the text until a resource exists.
private string Describe(IReadOnlyList<FluentCMS.Core.Results.Error> errors)
{
return string.Join(" ", errors.Select(error =>
{
var localized = L[error.Code];
return localized.ResourceNotFound ? error.Message : localized.Value;
}));
}
}
8 changes: 0 additions & 8 deletions src/FluentCMS.Admin/Components/Pages/Home.razor

This file was deleted.

3 changes: 3 additions & 0 deletions src/FluentCMS.Core/ChangeSets/ChangeSetErrorCodes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,7 @@ public static class ChangeSetErrorCodes
public const string RevertFixed = "revert_set_fixed";
public const string InitiatorNotAllowed = "initiator_not_allowed";
public const string ItemKindMismatch = "item_kind_mismatch";
public const string ListStatesMissing = "list_states_missing";
public const string OffsetInvalid = "offset_invalid";
public const string LimitInvalid = "limit_invalid";
}
48 changes: 48 additions & 0 deletions src/FluentCMS.Core/ChangeSets/ChangeSetListQuery.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using FluentCMS.Core.Results;

namespace FluentCMS.Core.ChangeSets;

// The change sets in any of the given states, optionally only those that entered their state at or after a time, one page at a time.
public sealed record ChangeSetListQuery
{
public const int MaxLimit = 200;

public required IReadOnlyCollection<ChangeSetState> States { get; init; }

public DateTimeOffset? EnteredSince { get; init; }

// Newest first puts the set that entered its state last first; otherwise the set that has waited longest comes first.
public bool NewestFirst { get; init; }

public int Offset { get; init; }

public int Limit { get; init; } = 50;

public Result Validate()
{
var errors = new List<Error>();

if (States is null || States.Count == 0 || States.Any(state => !Enum.IsDefined(state)))
{
errors.Add(new Error(ChangeSetErrorCodes.ListStatesMissing, "A list names one or more change set states to list."));
}

errors.AddRange(WindowErrors(Offset, Limit));

return errors.Count == 0 ? Result.Success() : Result.Failure(errors);
}

// The same window applies to the other lists the desk pages through, such as its stale facts.
public static IEnumerable<Error> WindowErrors(int offset, int limit)
{
if (offset < 0)
{
yield return new Error(ChangeSetErrorCodes.OffsetInvalid, $"The offset is {offset}; it is 0 or more.");
}

if (limit is < 1 or > MaxLimit)
{
yield return new Error(ChangeSetErrorCodes.LimitInvalid, $"The limit is {limit}; it is from 1 to {MaxLimit}.");
}
}
}
Loading
Loading