diff --git a/memex/Memex.Portal.Shared/Settings/InboxSettingsTab.cs b/memex/Memex.Portal.Shared/Settings/InboxSettingsTab.cs index 259c478ad9..aca4cf3861 100644 --- a/memex/Memex.Portal.Shared/Settings/InboxSettingsTab.cs +++ b/memex/Memex.Portal.Shared/Settings/InboxSettingsTab.cs @@ -31,7 +31,6 @@ public static class InboxSettingsTab public const string TabId = "Inbox"; private const string ResultDataId = "inboxResult"; private const string ListDataId = "inboxList"; - private const string SelectedDataId = "inboxSelected"; internal static UiControl BuildInboxContent(LayoutAreaHost host, StackControl stack) { @@ -49,13 +48,28 @@ internal static UiControl BuildInboxContent(LayoutAreaHost host, StackControl st .StartWith((UiControl?)Controls.Stack.WithWidth("100%"))); // The inbox — a TEMPLATE (Doc/GUI/DataBinding → "Templates first, data later"): the grid is - // declared at once and fed by the synced Admin/Inbox query; a mail is archived by SELECTING - // its row, then Archive below the grid. - host.UpdateData(SelectedDataId, MailRow.None(host.Localize("inbox.selectMail"))); + // declared at once and fed by the synced Admin/Inbox query; each row carries its own Archive + // button, a ROW-SCOPED action ("Row-scoped actions") — the click says which mail it is. var meshService = host.Hub.ServiceProvider.GetRequiredService(); var accessService = host.Hub.ServiceProvider.GetService(); - stack = stack.WithView(InboxRowsFeed(host) + stack = stack.WithView(MailGrid(host, InboxRowsFeed(host), + (ctx, row) => Archive(ctx, host, row, meshService, accessService))); + + return stack; + } + + /// The column of that holds each row's Archive button. + internal const int ArchiveColumn = 5; + + /// + /// The inbox grid over — the template half, separate from the feed so its + /// row-scoped action is testable. The Archive column's ONE button hands the row it was clicked in + /// to ; a click that carries no row does nothing. + /// + internal static DataGridControl MailGrid( + LayoutAreaHost host, IObservable> rows, Action archive) + => rows .BindGrid(ListDataId, host.Localize("inbox.empty"), message => host.Localize("inbox.listFailed", message)) .WithColumn(new PropertyColumnControl { Property = nameof(MailRow.From).ToCamelCase() } .WithTitle(host.Localize("inbox.column.from"))) @@ -67,65 +81,53 @@ internal static UiControl BuildInboxContent(LayoutAreaHost host, StackControl st .WithTitle(host.Localize("inbox.column.status"))) .WithColumn(new PropertyColumnControl { Property = nameof(MailRow.Preview).ToCamelCase() } .WithTitle(host.Localize("inbox.column.preview"))) - .WithClickAction(ctx => - { - if (ctx.Payload is DataGridCellClick { Item: { } item } - && item.As(ctx.Hub.JsonSerializerOptions, what: "inbox row") is { } row) - ctx.Host.UpdateData(SelectedDataId, row); - return Task.CompletedTask; - })); - - stack = stack.WithView(Controls.Stack.WithOrientation(Orientation.Horizontal) - .WithStyle("gap: 12px; align-items: center; margin-top: 8px;") - .WithView(new LabelControl(new JsonPointerReference( - LayoutAreaReference.GetDataPointer(SelectedDataId, nameof(MailRow.Display).ToCamelCase()))) - .WithStyle("flex: 1;")) - .WithView(Controls.Button(host.Localize("ui.archive")) - .WithAppearance(Appearance.Outline) - .WithClickAction(ctx => - { - ArchiveSelected(ctx, host, meshService, accessService); - return Task.CompletedTask; - }))); - - return stack; - } + .WithColumn(new TemplateColumnControl(Controls.Button(host.Localize("ui.archive")) + .WithAppearance(Appearance.Outline) + .WithClickAction(ctx => + { + if (ctx.RowAs() is { } row) + archive(ctx, row); + return Task.CompletedTask; + })) + .WithTitle(host.Localize("ui.archive"))); /// - /// Archives the mail selected in the grid, or says that one has to be selected first. The - /// selection is client input (a cell-click payload), so it only says WHICH mail: the target is - /// re-derived from the inbox query itself, read now, and the write — which runs as System — only - /// ever reaches a mail of that query in its CURRENT state. A path outside the inbox, or a mail - /// archived since it was selected, is refused. + /// Archives the mail of the clicked row, or says it is archived already. The row is client + /// input, so it only says WHICH mail: the target is re-derived from the inbox query itself, read + /// now (), and the write — which runs as System — only ever reaches a + /// mail that query lists, in its CURRENT state. A path outside the inbox is refused. /// - private static void ArchiveSelected( - UiActionContext ctx, LayoutAreaHost host, IMeshService meshService, AccessService? accessService) - => ctx.Host.Stream.GetDataStream(SelectedDataId).Take(1) - .SelectMany(selected => CurrentMail(host, selected?.Path)) - .Subscribe(row => + private static void Archive( + UiActionContext ctx, LayoutAreaHost host, MailRow clicked, IMeshService meshService, AccessService? accessService) + => CurrentMail(host, clicked.Path).Subscribe(row => + { + if (row is null) + { + ctx.Host.UpdateData(ResultDataId, Error(host.Localize("inbox.notInInbox"))); + return; + } + if (row.IsArchived) + { + ctx.Host.UpdateData(ResultDataId, Pending(Esc(host.Localize("inbox.alreadyArchived")))); + return; + } + if (accessService is null) { - if (row is null || row.IsArchived) - { - ctx.Host.UpdateData(ResultDataId, Pending(Esc(host.Localize("inbox.selectMail")))); - return; - } - if (accessService is null) - { - ctx.Host.UpdateData(ResultDataId, Error("No access service is registered; the mail cannot be archived.")); - return; - } - ctx.Host.UpdateData(ResultDataId, Pending($"Archiving mail from {Esc(row.FromAddress)}…")); - // The node itself, read once for the write (it exists: the inbox query just listed it). - ctx.Host.Hub.GetWorkspace().GetMeshNodeStream(row.Path).Take(1) - .SelectMany(node => EmailOf(node, ctx.Hub.JsonSerializerOptions) is { } email - ? Observable.Using( - () => accessService.ImpersonateAsSystem(), - _ => meshService.UpdateNode(node with { Content = email with { Status = EmailStatus.Archived } })) - : Observable.Throw(new InvalidOperationException($"{row.Path} holds no email."))) - .Subscribe( - _ => ctx.Host.UpdateData(ResultDataId, Success($"Archived mail from {Esc(row.FromAddress)}.")), - ex => ctx.Host.UpdateData(ResultDataId, Error(ex.Message))); - }, ex => ctx.Host.UpdateData(ResultDataId, Error(ex.Message))); + ctx.Host.UpdateData(ResultDataId, Error("No access service is registered; the mail cannot be archived.")); + return; + } + ctx.Host.UpdateData(ResultDataId, Pending($"Archiving mail from {Esc(row.FromAddress)}…")); + // The node itself, read once for the write (it exists: the inbox query just listed it). + ctx.Host.Hub.GetWorkspace().GetMeshNodeStream(row.Path).Take(1) + .SelectMany(node => EmailOf(node, ctx.Hub.JsonSerializerOptions) is { } email + ? Observable.Using( + () => accessService.ImpersonateAsSystem(), + _ => meshService.UpdateNode(node with { Content = email with { Status = EmailStatus.Archived } })) + : Observable.Throw(new InvalidOperationException($"{row.Path} holds no email."))) + .Subscribe( + _ => ctx.Host.UpdateData(ResultDataId, Success($"Archived mail from {Esc(row.FromAddress)}.")), + ex => ctx.Host.UpdateData(ResultDataId, Error(ex.Message))); + }, ex => ctx.Host.UpdateData(ResultDataId, Error(ex.Message))); /// /// The row of the inbox query, as it is NOW, whose path is — or null when @@ -163,9 +165,8 @@ internal static IObservable> InboxRowsFeed(LayoutAreaHost /// One row of the inbox grid — display text plus what Archive needs. internal record MailRow( string From, string Subject, string Received, string Status, string Preview, - string Path, string FromAddress, bool IsArchived, string Display) + string Path, string FromAddress, bool IsArchived) { - internal static MailRow None(string hint) => new("", "", "", "", "", "", "", false, hint); internal static MailRow Of(MeshNode node, MeshWeaver.Mesh.Email email, string status) { @@ -179,8 +180,7 @@ internal static MailRow Of(MeshNode node, MeshWeaver.Mesh.Email email, string st body.Length > 140 ? body[..140] + "…" : body, node.Path ?? "", email.From, - email.Status == EmailStatus.Archived, - $"{from} · {email.Subject}"); + email.Status == EmailStatus.Archived); } } diff --git a/memex/Memex.Portal.Shared/Settings/InvitationsSettingsTab.cs b/memex/Memex.Portal.Shared/Settings/InvitationsSettingsTab.cs index ac0afd9ed4..b761a24ac1 100644 --- a/memex/Memex.Portal.Shared/Settings/InvitationsSettingsTab.cs +++ b/memex/Memex.Portal.Shared/Settings/InvitationsSettingsTab.cs @@ -34,7 +34,6 @@ public static class InvitationsSettingsTab private const string ResultDataId = "invitationResult"; private const string FormDataId = "invitationForm"; private const string ListDataId = "invitationList"; - private const string SelectedDataId = "invitationSelected"; internal static UiControl BuildInvitationsContent( LayoutAreaHost host, StackControl stack) @@ -119,10 +118,26 @@ internal static UiControl BuildInvitationsContent( "

Invitations

")); // The invitations — a TEMPLATE (Doc/GUI/DataBinding → "Templates first, data later"): the - // grid is declared at once and fed by the synced Admin/Invitation query; an invitation is - // revoked by SELECTING its row, then Revoke below the grid. - host.UpdateData(SelectedDataId, InvitationRow.None(host.Localize("invitations.selectInvitation"))); - stack = stack.WithView(InvitationRowsFeed(host) + // grid is declared at once and fed by the synced Admin/Invitation query; each row carries its + // own Revoke button, a ROW-SCOPED action ("Row-scoped actions") — the click says which + // invitation it is. + stack = stack.WithView(InvitationGrid(host, InvitationRowsFeed(host), + (ctx, row) => Revoke(ctx, host, row, invitationService))); + + return stack; + } + + /// The column of that holds each row's Revoke button. + internal const int RevokeColumn = 8; + + /// + /// The invitation grid over — the template half, separate from the feed so + /// its row-scoped action is testable. The Revoke column's ONE button hands the row it was clicked + /// in to ; a click that carries no row does nothing. + /// + internal static DataGridControl InvitationGrid( + LayoutAreaHost host, IObservable> rows, Action revoke) + => rows .BindGrid(ListDataId, host.Localize("invitations.none"), message => host.Localize("invitations.listFailed", message)) .WithColumn(new PropertyColumnControl { Property = nameof(InvitationRow.Email).ToCamelCase() } .WithTitle(host.Localize("invitations.column.email"))) @@ -140,46 +155,30 @@ internal static UiControl BuildInvitationsContent( .WithTitle(host.Localize("invitations.column.space"))) .WithColumn(new PropertyColumnControl { Property = nameof(InvitationRow.Note).ToCamelCase() } .WithTitle(host.Localize("invitations.column.note"))) - .WithClickAction(ctx => - { - if (ctx.Payload is DataGridCellClick { Item: { } item } - && item.As(ctx.Hub.JsonSerializerOptions, what: "invitation row") is { } row) - ctx.Host.UpdateData(SelectedDataId, row); - return Task.CompletedTask; - })); - - stack = stack.WithView(Controls.Stack.WithOrientation(Orientation.Horizontal) - .WithStyle("gap: 12px; align-items: center; margin-top: 8px;") - .WithView(new LabelControl(new JsonPointerReference( - LayoutAreaReference.GetDataPointer(SelectedDataId, nameof(InvitationRow.Display).ToCamelCase()))) - .WithStyle("flex: 1;")) - .WithView(Controls.Button(host.Localize("ui.revoke")) - .WithAppearance(Appearance.Outline) - .WithClickAction(ctx => - { - RevokeSelected(ctx, host, invitationService); - return Task.CompletedTask; - }))); - - return stack; - } - - /// Revokes the PENDING invitation selected in the grid, or says that one has to be - /// selected first. - /// The selection is client input; the target is re-derived from the invitation query, - /// read now, so only a listed invitation that is STILL pending is revoked. - private static void RevokeSelected(UiActionContext ctx, LayoutAreaHost host, InvitationService invitationService) - => ctx.Host.Stream.GetDataStream(SelectedDataId).Take(1) - .SelectMany(selected => CurrentInvitation(host, selected?.Path)) - .Subscribe(row => + .WithColumn(new TemplateColumnControl(Controls.Button(host.Localize("ui.revoke")) + .WithAppearance(Appearance.Outline) + .WithClickAction(ctx => + { + if (ctx.RowAs() is { } row) + revoke(ctx, row); + return Task.CompletedTask; + })) + .WithTitle(host.Localize("ui.revoke"))); + + /// Revokes the clicked row's invitation, or says that only a pending invitation can + /// be revoked. The row is client input: the invitation is re-derived from the invitation query, + /// read now (), so only a listed invitation that is STILL pending + /// is revoked. + private static void Revoke(UiActionContext ctx, LayoutAreaHost host, InvitationRow clicked, InvitationService invitationService) + => CurrentInvitation(host, clicked.Path).Subscribe(row => { if (row is null || !row.IsPending) { - ctx.Host.UpdateData(ResultDataId, PendingHtml(Esc(host.Localize("invitations.selectInvitation")))); + ctx.Host.UpdateData(ResultDataId, PendingHtml(Esc(host.Localize("invitations.notPending")))); return; } ctx.Host.UpdateData(ResultDataId, PendingHtml($"Revoking {Esc(row.Email)}…")); - // The node itself, read once for the write (it exists: the row came from it). + // The node itself, read once for the write (it exists: the invitation query just listed it). ctx.Host.Hub.GetWorkspace().GetMeshNodeStream(row.Path).Take(1) .SelectMany(node => InvitationService.TryGetInvitation(node, ctx.Hub.JsonSerializerOptions) is { } inv ? invitationService.Revoke(node, inv) @@ -223,9 +222,8 @@ internal static IObservable> InvitationRowsFeed(Lay /// One row of the invitation grid — display text plus what Revoke needs. internal record InvitationRow( string Email, string Status, string Invited, string InvitedBy, string Emailed, string Accepted, - string Space, string Note, string Path, bool IsPending, string Display) + string Space, string Note, string Path, bool IsPending) { - internal static InvitationRow None(string hint) => new("", "", "", "", "", "", "", "", "", false, hint); internal static InvitationRow Of(MeshNode node, Invitation inv, string status) => new( inv.Email, @@ -237,8 +235,7 @@ internal record InvitationRow( inv.SpacePath ?? "", inv.Note ?? "", node.Path ?? "", - inv.Status == InvitationStatus.Pending, - $"{inv.Email} · {status}"); + inv.Status == InvitationStatus.Pending); } private static string Esc(string s) => System.Web.HttpUtility.HtmlEncode(s); diff --git a/memex/Memex.Portal.Shared/Settings/ServiceIdentitiesSettingsTab.cs b/memex/Memex.Portal.Shared/Settings/ServiceIdentitiesSettingsTab.cs index 9fbb3df2cc..3aee0eed5f 100644 --- a/memex/Memex.Portal.Shared/Settings/ServiceIdentitiesSettingsTab.cs +++ b/memex/Memex.Portal.Shared/Settings/ServiceIdentitiesSettingsTab.cs @@ -35,7 +35,6 @@ public static class ServiceIdentitiesSettingsTab private const string ResultDataId = "serviceIdentityResult"; private const string ListDataId = "serviceIdentityList"; private const string TokensDataId = "serviceIdentityTokens"; - private const string SelectedTokenDataId = "serviceIdentitySelectedToken"; /// The query every emission of the identity list is read from. internal const string IdentitiesQuery = @@ -59,8 +58,6 @@ internal static UiControl BuildContent(LayoutAreaHost host, StackControl stack) ["role"] = "Editor", }); - host.UpdateData(SelectedTokenDataId, NoSelection(host.Localize("serviceIdentities.selectToken"))); - stack = stack .WithView(Controls.Title(host.Localize("serviceIdentities.title"), 2)) .WithView(Controls.Markdown(host.Localize("serviceIdentities.intro"))); @@ -135,8 +132,9 @@ internal static UiControl BuildContent(LayoutAreaHost host, StackControl stack) // The live lists — TEMPLATES (Doc/GUI/DataBinding → "Templates first, data later"): one grid // of every identity, one grid of every identity's tokens, each declared at once and fed by a - // synced query (a create, a revoke or a new token re-emits it — no refresh trigger). A token - // is acted on by SELECTING its row, then Rotate / Revoke below the grid. + // synced query (a create, a revoke or a new token re-emits it — no refresh trigger). Each token + // row carries its own Rotate and Revoke buttons, ROW-SCOPED actions ("Row-scoped actions"): + // the click says which token it is. var failed = (Func)(message => $"{host.Localize("serviceIdentities.error")} {message}"); stack = stack .WithView(Controls.Title(host.Localize("serviceIdentities.listHeading"), 3)) @@ -151,89 +149,102 @@ internal static UiControl BuildContent(LayoutAreaHost host, StackControl stack) .WithColumn(new PropertyColumnControl { Property = nameof(IdentityRow.IssuedBy).ToCamelCase() } .WithTitle(host.Localize("serviceIdentities.column.issuedBy")))) .WithView(Controls.Title(host.Localize("serviceIdentities.tokensHeading"), 3)) - .WithView(TokenRowsFeed(host, tokens) - .BindGrid(TokensDataId, host.Localize("serviceIdentities.noTokens"), failed) - .WithColumn(new PropertyColumnControl { Property = nameof(TokenRow.ServiceId).ToCamelCase() } - .WithTitle(host.Localize("serviceIdentities.column.id"))) - .WithColumn(new PropertyColumnControl { Property = nameof(TokenRow.Label).ToCamelCase() } - .WithTitle(host.Localize("serviceIdentities.field.label"))) - .WithColumn(new PropertyColumnControl { Property = nameof(TokenRow.HashPrefix).ToCamelCase() } - .WithTitle(host.Localize("serviceIdentities.column.token"))) - .WithColumn(new PropertyColumnControl { Property = nameof(TokenRow.Status).ToCamelCase() } - .WithTitle(host.Localize("serviceIdentities.column.status"))) - .WithColumn(new PropertyColumnControl { Property = nameof(TokenRow.Created).ToCamelCase() } - .WithTitle(host.Localize("apiTokens.created"))) - .WithColumn(new PropertyColumnControl { Property = nameof(TokenRow.Expires).ToCamelCase() } - .WithTitle(host.Localize("apiTokens.expires"))) - .WithColumn(new PropertyColumnControl { Property = nameof(TokenRow.LastUsed).ToCamelCase() } - .WithTitle(host.Localize("apiTokens.lastUsed"))) - .WithClickAction(ctx => - { - if (ctx.Payload is DataGridCellClick { Item: { } item } - && item.As(ctx.Hub.JsonSerializerOptions, what: "service-identity token row") is { } row) - ctx.Host.UpdateData(SelectedTokenDataId, row); - return Task.CompletedTask; - })) - .WithView(TokenActions(host, tokens)); + .WithView(TokenGrid(host, TokenRowsFeed(host, tokens), + rotate: (ctx, token) => Rotate(ctx, host, tokens, token), + revoke: (ctx, token) => Revoke(ctx, host, tokens, token))); return stack; } + /// The column of that holds each row's Rotate button. + internal const int RotateColumn = 7; + + /// The column of that holds each row's Revoke button. + internal const int RevokeColumn = 8; + /// - /// Rotate / Revoke for the token selected in the grid. The selection is a bound label; the - /// buttons read it when clicked, and refuse — saying so — when nothing (or a revoked token) is - /// selected. + /// The token grid over — the template half, separate from the feed so its + /// row-scoped actions are testable. The Rotate and Revoke columns each hold ONE button that hands + /// the row it was clicked in to / ; a click that + /// carries no row does nothing. /// - private static UiControl TokenActions(LayoutAreaHost host, ApiTokenService tokens) - => Row() - .WithView(new LabelControl(new JsonPointerReference( - LayoutAreaReference.GetDataPointer(SelectedTokenDataId, nameof(TokenRow.Display).ToCamelCase()))) - .WithStyle("flex: 1;")) - .WithView(Controls.Button(host.Localize("serviceIdentities.rotate")) + internal static DataGridControl TokenGrid( + LayoutAreaHost host, IObservable> rows, + Action rotate, Action revoke) + => rows + .BindGrid(TokensDataId, host.Localize("serviceIdentities.noTokens"), + message => $"{host.Localize("serviceIdentities.error")} {message}") + .WithColumn(new PropertyColumnControl { Property = nameof(TokenRow.ServiceId).ToCamelCase() } + .WithTitle(host.Localize("serviceIdentities.column.id"))) + .WithColumn(new PropertyColumnControl { Property = nameof(TokenRow.Label).ToCamelCase() } + .WithTitle(host.Localize("serviceIdentities.field.label"))) + .WithColumn(new PropertyColumnControl { Property = nameof(TokenRow.HashPrefix).ToCamelCase() } + .WithTitle(host.Localize("serviceIdentities.column.token"))) + .WithColumn(new PropertyColumnControl { Property = nameof(TokenRow.Status).ToCamelCase() } + .WithTitle(host.Localize("serviceIdentities.column.status"))) + .WithColumn(new PropertyColumnControl { Property = nameof(TokenRow.Created).ToCamelCase() } + .WithTitle(host.Localize("apiTokens.created"))) + .WithColumn(new PropertyColumnControl { Property = nameof(TokenRow.Expires).ToCamelCase() } + .WithTitle(host.Localize("apiTokens.expires"))) + .WithColumn(new PropertyColumnControl { Property = nameof(TokenRow.LastUsed).ToCamelCase() } + .WithTitle(host.Localize("apiTokens.lastUsed"))) + .WithColumn(RowButtonColumn(host.Localize("serviceIdentities.rotate"), rotate)) + .WithColumn(RowButtonColumn(host.Localize("ui.revoke"), revoke)); + + /// A template column holding ONE button whose click hands its row to . + private static TemplateColumnControl RowButtonColumn(string label, Action act) + => new TemplateColumnControl(Controls.Button(label) .WithAppearance(Appearance.Outline) - .WithClickAction(ctx => WithSelectedToken(ctx, host, tokens, token => + .WithClickAction(ctx => { - // Same term length as the token it replaces, counted from now. - DateTimeOffset? expiresAt = token.ExpiresAt is { } old - ? DateTimeOffset.UtcNow + (old - token.CreatedAt) - : null; - ServiceIdentities.Rotate(tokens, token.ServiceId, token.NodePath, token.Label, expiresAt) - .Subscribe( - result => ctx.Host.UpdateData(ResultDataId, TokenShownOnce(host, result)), - ex => ctx.Host.UpdateData(ResultDataId, - $"{host.Localize("serviceIdentities.error")} {ex.Message}")); - }))) - .WithView(Controls.Button(host.Localize("ui.revoke")) - .WithAppearance(Appearance.Outline) - .WithClickAction(ctx => WithSelectedToken(ctx, host, tokens, token => - tokens.RevokeToken(token.NodePath).Subscribe( - ok => ctx.Host.UpdateData(ResultDataId, ok - ? $"{host.Localize("apiTokens.revoked")} **{token.Label}**" - : host.Localize("apiTokens.revokeFailed")), - ex => ctx.Host.UpdateData(ResultDataId, - $"{host.Localize("apiTokens.revokeFailed")} {ex.Message}"))))); - - /// Hands the selected token to , or says that a live token has - /// to be selected first. The selection is client input, so it only says WHICH token: the token - /// handed on is the token feed's row as it is NOW (service id, node path and revoked flag from the - /// server), and a token the feed does not list, or one revoked since it was selected, is refused. - private static Task WithSelectedToken( - UiActionContext ctx, LayoutAreaHost host, ApiTokenService tokens, Action act) - { - // The slot is seeded with NoSelection when the tab is built, so it always holds a value. - ctx.Host.Stream.GetDataStream(SelectedTokenDataId) - .Take(1) - .SelectMany(selected => CurrentToken(host, tokens, selected?.NodePath)) - .Subscribe(token => + if (ctx.RowAs() is { } token) + act(ctx, token); + return Task.CompletedTask; + })) + .WithTitle(label); + + /// Rotates the clicked row's token, or says that only a live token can be rotated. + /// The row only says WHICH token: service id, node path, term and revoked flag come from the + /// token feed as it is NOW (). + private static void Rotate(UiActionContext ctx, LayoutAreaHost host, ApiTokenService tokens, TokenRow clicked) + => WithLiveToken(ctx, host, tokens, clicked, token => + { + // Same term length as the token it replaces, counted from now. + DateTimeOffset? expiresAt = token.ExpiresAt is { } old + ? DateTimeOffset.UtcNow + (old - token.CreatedAt) + : null; + ServiceIdentities.Rotate(tokens, token.ServiceId, token.NodePath, token.Label, expiresAt) + .Subscribe( + result => ctx.Host.UpdateData(ResultDataId, TokenShownOnce(host, result)), + ex => ctx.Host.UpdateData(ResultDataId, + $"{host.Localize("serviceIdentities.error")} {ex.Message}")); + }); + + /// Revokes the clicked row's token, or says that only a live token can be revoked. + /// The token is the feed's row as it is NOW (). + private static void Revoke(UiActionContext ctx, LayoutAreaHost host, ApiTokenService tokens, TokenRow clicked) + => WithLiveToken(ctx, host, tokens, clicked, token => + tokens.RevokeToken(token.NodePath).Subscribe( + ok => ctx.Host.UpdateData(ResultDataId, ok + ? $"{host.Localize("apiTokens.revoked")} **{token.Label}**" + : host.Localize("apiTokens.revokeFailed")), + ex => ctx.Host.UpdateData(ResultDataId, + $"{host.Localize("apiTokens.revokeFailed")} {ex.Message}"))); + + /// Hands the token feed's CURRENT row for the clicked token to ; + /// a token the feed does not list, or one revoked since the render, is refused on the result + /// line. + private static void WithLiveToken( + UiActionContext ctx, LayoutAreaHost host, ApiTokenService tokens, TokenRow clicked, Action act) + => CurrentToken(host, tokens, clicked.NodePath).Subscribe( + token => { if (token is null || token.IsRevoked) - ctx.Host.UpdateData(ResultDataId, host.Localize("serviceIdentities.selectToken")); + ctx.Host.UpdateData(ResultDataId, host.Localize("serviceIdentities.tokenRevoked")); else act(token); - }, ex => ctx.Host.UpdateData(ResultDataId, - $"{host.Localize("serviceIdentities.error")} {ex.Message}")); - return Task.CompletedTask; - } + }, + ex => ctx.Host.UpdateData(ResultDataId, $"{host.Localize("serviceIdentities.error")} {ex.Message}")); /// The token feed's row, as it is NOW, whose node path is /// — or null when no identity lists such a token. @@ -277,8 +288,7 @@ TokenRow ToRow(IdentityRow identity, ApiTokenInfo token) token.NodePath, token.IsRevoked, token.CreatedAt, - token.ExpiresAt, - $"{token.Label} · {token.HashPrefix} · {identity.Name} ({identity.ObjectId})"); + token.ExpiresAt); } return IdentityRowsFeed(host) @@ -314,15 +324,12 @@ internal record IdentityRow(string ObjectId, string Name, string Status, string /// /// One row of the token grid — every display column as text for the viewer, plus what Rotate and - /// Revoke need (, the term) and the selection label (). + /// Revoke need (, the term). /// internal record TokenRow( string ServiceId, string Label, string HashPrefix, string Status, string Created, string Expires, string LastUsed, - string NodePath, bool IsRevoked, DateTimeOffset CreatedAt, DateTimeOffset? ExpiresAt, string Display); - - /// The selection before a row is clicked: no token, and the hint as its label. - private static TokenRow NoSelection(string hint) => new("", "", "", "", "", "", "", "", false, default, null, hint); + string NodePath, bool IsRevoked, DateTimeOffset CreatedAt, DateTimeOffset? ExpiresAt); private static string TokenShownOnce(LayoutAreaHost host, TokenCreationResult result) => $"**{host.Localize("apiTokens.copyNow")}**\n\n```\n{result.RawToken}\n```"; diff --git a/src/MeshWeaver.Documentation/Data/GUI/DataBinding.md b/src/MeshWeaver.Documentation/Data/GUI/DataBinding.md index 2599a83261..f8555bc4f3 100644 --- a/src/MeshWeaver.Documentation/Data/GUI/DataBinding.md +++ b/src/MeshWeaver.Documentation/Data/GUI/DataBinding.md @@ -160,7 +160,7 @@ Every projection is a pure `Select` over the hub's own node, published with `Tem - **Another node's live state — embed THAT node's own area.** The GitHub-sync tab's activity panel used to subscribe the activity node twice and rebuild hand-made HTML plus a Cancel button on every progress tick. It now embeds `new LayoutAreaControl(new Address(activityPath), new LayoutAreaReference(ActivityLayoutAreas.ProgressArea)).WithSpinnerType(SpinnerType.Skeleton)`: the activity's hub renders its log, status and Cancel, and the tab reads nothing. - `LayoutTemplate.DeferredViews` / `Descendants` (`MeshWeaver.Layout`) is the shared form of the reference test's check: an empty list means the tree is a template. -- **A list with a per-row action (archive, revoke, rotate)** — a fed grid plus SELECTION: `WithClickAction` on the grid reads the clicked row from `DataGridCellClick` with `.As(…)` and writes it to a `/data` slot (seeded with a "nothing selected" row whose label is the hint); a bound label shows the selection and the action buttons below the grid act on it, refusing — and saying so — when nothing actionable is selected. The settings tabs Inbox, Invitations and Service identities use it; a bound row whose button carries its row ([Row-scoped actions](#row-scoped-actions)) is the shape that replaces it. Keep the feed in its own function (it takes the host, builds no control) and the action in its own function, so the template function reads nothing. +- **A list with a per-row action (archive, revoke, rotate)** — a fed grid with a ROW-SCOPED button in a template column (see "Row-scoped actions" below): the button is declared once, and its click carries the row it was clicked in, which the action reads with `ctx.RowAs()`. The settings tabs Inbox (Archive), Invitations (Revoke) and Service identities (a token's Rotate and Revoke) use it — `InboxSettingsTab.MailGrid`, `InvitationsSettingsTab.InvitationGrid`, `ServiceIdentitiesSettingsTab.TokenGrid`, pinned by `SettingsRowActionsActOnTheClickedRowTest` (row k acts on row k; a list that changed since the render still acts on the clicked row; a click with no row does nothing). An action that applies only to some rows (an archived mail, an accepted invitation, a revoked token) refuses on the result line and says why. Keep the feed in its own function (it takes the host, builds no control), the grid in its own function that takes the feed and the action (so the template function reads nothing and the row routing is testable), and the action in its own function. The older shape, written before row-scoped actions existed, was a fed grid plus SELECTION (`DataGridCellClick` into a `/data` slot, buttons below the grid acting on it); do not write new ones. **Not convertible at the helper:** `LayoutHelperExtensions.StreamView` hands the caller's `viewFactory` the loaded items, so its contract IS the bake; it retires when its callers (`MeshNodeLayoutAreas.Thumbnail`/`Metadata` here, three `MeshWeaver.Graph.Views` areas in MeshWeaver.Plugins) are templates. **A list with a per-row action** (revoke, archive, rotate) is a bound list too: a button inside a bound row (`ItemTemplateControl`, `TemplateColumnControl`) posts a `ClickedEvent` that carries its row — see [Row-scoped actions](#row-scoped-actions) below; `DataGridControl.WithClickAction` + `DataGridCellClick` (the row arrives as the payload — `GitHistoryTab`) remains the shape for a click on the cell itself. diff --git a/src/MeshWeaver.Messaging.Hub/Localization/strings.de.json b/src/MeshWeaver.Messaging.Hub/Localization/strings.de.json index af72c7d14b..ce1c371a0c 100644 --- a/src/MeshWeaver.Messaging.Hub/Localization/strings.de.json +++ b/src/MeshWeaver.Messaging.Hub/Localization/strings.de.json @@ -2074,8 +2074,9 @@ "instances.error.listFailed": "Ihre Installationen konnten nicht aufgelistet werden: {0}", "serviceIdentities.tokensHeading": "Tokens", "serviceIdentities.column.token": "Token", - "serviceIdentities.selectToken": "Wählen Sie im Raster ein aktives Token aus und erneuern oder widerrufen Sie es dann.", - "inbox.selectMail": "Wählen Sie im Raster eine E-Mail aus und archivieren Sie sie dann.", + "serviceIdentities.tokenRevoked": "Dieses Token ist bereits widerrufen — nur ein aktives Token kann erneuert oder widerrufen werden.", + "inbox.notInInbox": "Diese E-Mail ist nicht im Posteingang.", + "inbox.alreadyArchived": "Diese E-Mail ist bereits archiviert.", "inbox.empty": "Der Posteingang ist leer.", "inbox.listFailed": "Der Posteingang konnte nicht gelesen werden: {0}", "inbox.column.from": "Von", @@ -2086,7 +2087,7 @@ "inbox.status.new": "Neu", "inbox.status.read": "Gelesen", "inbox.status.archived": "Archiviert", - "invitations.selectInvitation": "Wählen Sie im Raster eine ausstehende Einladung aus und widerrufen Sie sie dann.", + "invitations.notPending": "Nur eine ausstehende Einladung kann widerrufen werden.", "invitations.none": "Noch keine Einladungen.", "invitations.listFailed": "Die Einladungen konnten nicht gelesen werden: {0}", "invitations.column.email": "E-Mail", diff --git a/src/MeshWeaver.Messaging.Hub/Localization/strings.en.json b/src/MeshWeaver.Messaging.Hub/Localization/strings.en.json index d91a0543b7..9f1d96c56b 100644 --- a/src/MeshWeaver.Messaging.Hub/Localization/strings.en.json +++ b/src/MeshWeaver.Messaging.Hub/Localization/strings.en.json @@ -2074,8 +2074,9 @@ "instances.error.listFailed": "Could not list your installations: {0}", "serviceIdentities.tokensHeading": "Tokens", "serviceIdentities.column.token": "Token", - "serviceIdentities.selectToken": "Select an active token in the grid, then rotate or revoke it.", - "inbox.selectMail": "Select a mail in the grid, then archive it.", + "serviceIdentities.tokenRevoked": "This token is revoked already — only an active token can be rotated or revoked.", + "inbox.notInInbox": "That mail is not in the inbox.", + "inbox.alreadyArchived": "This mail is archived already.", "inbox.empty": "Inbox is empty.", "inbox.listFailed": "Could not read the inbox: {0}", "inbox.column.from": "From", @@ -2086,7 +2087,7 @@ "inbox.status.new": "New", "inbox.status.read": "Read", "inbox.status.archived": "Archived", - "invitations.selectInvitation": "Select a pending invitation in the grid, then revoke it.", + "invitations.notPending": "Only a pending invitation can be revoked.", "invitations.none": "No invitations yet.", "invitations.listFailed": "Could not read the invitations: {0}", "invitations.column.email": "Email", diff --git a/test/Memex.Portal.Shared.Test/SettingsRowActionsActOnTheClickedRowTest.cs b/test/Memex.Portal.Shared.Test/SettingsRowActionsActOnTheClickedRowTest.cs new file mode 100644 index 0000000000..0af49261dc --- /dev/null +++ b/test/Memex.Portal.Shared.Test/SettingsRowActionsActOnTheClickedRowTest.cs @@ -0,0 +1,196 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using Memex.Portal.Shared.Settings; +using MeshWeaver.Data; +using MeshWeaver.Fixture; +using MeshWeaver.Layout; +using MeshWeaver.Layout.Client; +using MeshWeaver.Layout.DataGrid; +using MeshWeaver.Messaging; +using Xunit; + +namespace Memex.Portal.Shared.Test; + +/// +/// The admin settings lists with a per-row action — Inbox (Archive), Invitations (Revoke) and the +/// service-identity tokens (Rotate, Revoke) — carry that action as a ROW-SCOPED button +/// (Doc/GUI/DataBinding → "Row-scoped actions"): ONE button declared in a template column, whose +/// click says which row it came from. Before, the first data-binding conversion had downgraded each +/// to "select the row, then press the button below the grid". +/// +/// Each grid's action is handed in, so this pins exactly the part that changed: which row the +/// action receives. Clicking row k acts on row k for every k; a list that changes between the render +/// and the click still acts on the row that was clicked; a click with no row does nothing (negative +/// control). +/// +public class SettingsRowActionsActOnTheClickedRowTest(ITestOutputHelper output) : HubTestBase(output) +{ + private const string InboxArea = "Inbox"; + private const string InvitationsArea = "Invitations"; + private const string TokensArea = "Tokens"; + + private static InboxSettingsTab.MailRow Mail(string id) + => new($"{id} <{id}@example.com>", $"Subject {id}", "2026-10-01 10:00", "New", "…", + $"Admin/Inbox/{id}", $"{id}@example.com", IsArchived: false); + + private static InvitationsSettingsTab.InvitationRow Invitation(string id) + => new($"{id}@example.com", "Pending", "2026-10-01", "admin", "", "", "", "", + $"Admin/Invitation/{id}", IsPending: true); + + private static ServiceIdentitiesSettingsTab.TokenRow Token(string id) + => new("svc", id, "mw_ab12", "Active", "2026-10-01", "∞", "∞", + $"Admin/ServiceIdentity/svc/_Api/{id}", IsRevoked: false, DateTimeOffset.UnixEpoch, null); + + private static readonly ImmutableList Ids = ["alpha", "bravo", "charlie"]; + + private readonly BehaviorSubject> mails = new(Ids.Select(Mail)); + private readonly BehaviorSubject> invitations = new(Ids.Select(Invitation)); + private readonly BehaviorSubject> tokens = new(Ids.Select(Token)); + + /// Every action an owner ran, as "action:path" — hot: each click subscribes before it submits. + private readonly Subject acted = new(); + + /// + protected override MessageHubConfiguration ConfigureHost(MessageHubConfiguration configuration) + => base.ConfigureHost(configuration) + .WithTypes(typeof(InboxSettingsTab.MailRow), typeof(InvitationsSettingsTab.InvitationRow), + typeof(ServiceIdentitiesSettingsTab.TokenRow)) + .AddLayout(layout => layout + .WithView(InboxArea, (host, _) => InboxSettingsTab.MailGrid(host, mails, + (_, row) => acted.OnNext($"archive:{row.Path}"))) + .WithView(InvitationsArea, (host, _) => InvitationsSettingsTab.InvitationGrid(host, invitations, + (_, row) => acted.OnNext($"revoke:{row.Path}"))) + .WithView(TokensArea, (host, _) => ServiceIdentitiesSettingsTab.TokenGrid(host, tokens, + rotate: (_, row) => acted.OnNext($"rotate:{row.NodePath}"), + revoke: (_, row) => acted.OnNext($"revoke:{row.NodePath}")))); + + /// + protected override MessageHubConfiguration ConfigureClient(MessageHubConfiguration configuration) + => base.ConfigureClient(configuration).AddLayoutClient(d => d); + + /// The grids under test: area, data slot, the row property naming the target, and each + /// row-scoped button as (column, action name). + public static TheoryData Buttons => new() + { + { InboxArea, "inboxList", "path", InboxSettingsTab.ArchiveColumn, "archive" }, + { InvitationsArea, "invitationList", "path", InvitationsSettingsTab.RevokeColumn, "revoke" }, + { TokensArea, "serviceIdentityTokens", "nodePath", ServiceIdentitiesSettingsTab.RotateColumn, "rotate" }, + { TokensArea, "serviceIdentityTokens", "nodePath", ServiceIdentitiesSettingsTab.RevokeColumn, "revoke" }, + }; + + /// The button in row k acts on row k, for every k. + [Theory] + [MemberData(nameof(Buttons))] + public async Task TheButtonInRowKActsOnRowK(string area, string dataId, string key, int column, string action) + { + var (stream, buttonArea) = await OpenGrid(area, column); + var rendered = await RenderedRows(stream, dataId, key, Ids.Count); + + for (var k = 0; k < Ids.Count; k++) + { + var target = rendered[k].GetProperty(key).GetString(); + var done = await ClickAndReadAction(stream, buttonArea, AsTheGridRendersIt(rendered[k])); + done.Should().Be($"{action}:{target}", $"the {action} button in row {k} was clicked"); + } + } + + /// The list changes between the render and the click — a row inserted above, the clicked + /// one gone — and the click still acts on the row that was clicked. + [Theory] + [MemberData(nameof(Buttons))] + public async Task AfterAChangeTheClickStillActsOnTheClickedRow( + string area, string dataId, string key, int column, string action) + { + var (stream, buttonArea) = await OpenGrid(area, column); + var clickedRow = (await RenderedRows(stream, dataId, key, Ids.Count))[1]; // bravo + var clickedTarget = clickedRow.GetProperty(key).GetString(); + + string[] next = ["aardvark", "abacus", "charlie"]; // bravo gone, slot 1 is abacus + Push(area, next); + await RenderedRows(stream, dataId, key, next.Length, firstContains: "aardvark"); + + var done = await ClickAndReadAction(stream, buttonArea, AsTheGridRendersIt(clickedRow)); + done.Should().Be($"{action}:{clickedTarget}", + "the person clicked bravo; the row that has since moved into its slot is not theirs to act on"); + } + + /// NEGATIVE CONTROL: the same button clicked with no row does nothing — the target comes + /// from the row on the click and from nothing else. + [Theory] + [MemberData(nameof(Buttons))] + public async Task AClickWithNoRowDoesNothing(string area, string dataId, string key, int column, string action) + { + var (stream, buttonArea) = await OpenGrid(area, column); + await RenderedRows(stream, dataId, key, Ids.Count); + + var next = new ReplaySubject(1); + using var _ = acted.Subscribe(next); + Submit(stream, buttonArea, row: null); + + await next.Should().NotEmit(500.Milliseconds(), + $"with no row the {action} action cannot know which item, and must not guess", + TestContext.Current.CancellationToken); + } + + // ── Harness ───────────────────────────────────────────────────────────────────────────────── + + private void Push(string area, string[] ids) + { + switch (area) + { + case InboxArea: mails.OnNext(ids.Select(Mail)); break; + case InvitationsArea: invitations.OnNext(ids.Select(Invitation)); break; + default: tokens.OnNext(ids.Select(Token)); break; + } + } + + private async Task<(ISynchronizationStream Stream, string ButtonArea)> OpenGrid(string area, int column) + { + var stream = GetClient().GetWorkspace().GetRemoteStream( + CreateHostAddress(), new LayoutAreaReference(area)); + var buttonArea = $"{area}/{DataGridControl.TemplateColumnArea(column)}"; + await stream.GetControlStream(buttonArea).Should().Within(10.Seconds()).Match( + c => c is ButtonControl, + "the action column's button is rendered into the template column's area", + TestContext.Current.CancellationToken); + return (stream, buttonArea); + } + + private static async Task> RenderedRows( + ISynchronizationStream stream, string dataId, string key, int count, string? firstContains = null) + { + var all = await stream.GetDataStream( + new JsonPointerReference(LayoutAreaReference.GetDataPointer(dataId))) + .Should().Within(10.Seconds()).Match( + r => r.ValueKind == JsonValueKind.Array && r.GetArrayLength() == count + && (firstContains is null + || (r[0].GetProperty(key).GetString() ?? "").Contains(firstContains, StringComparison.Ordinal)), + "the client mirror holds the rows the grid renders", + TestContext.Current.CancellationToken); + return [.. all.EnumerateArray().Select(r => r.Clone())]; + } + + /// The grid client's row: a JSON object with no pointer (a grid sorts and pages). + private static RowContext AsTheGridRendersIt(JsonElement row) => new() { Value = JsonObject.Create(row) }; + + private static void Submit(ISynchronizationStream stream, string area, RowContext? row) + => stream.SubmitUserAction(new ClickedEvent(area, stream.StreamId) { Row = row }, + actingUser: null, onRefused: null, onAccepted: null); + + private async Task ClickAndReadAction(ISynchronizationStream stream, string area, RowContext row) + { + var next = new ReplaySubject(1); + using var _ = acted.Subscribe(next); + Submit(stream, area, row); + var done = await next.Should().Within(10.Seconds()).Emit( + "the row's button runs its action", TestContext.Current.CancellationToken); + return done!; + } +}