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 MeshWeaver.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
<Project Path="src/MeshWeaver.Reactive.Assertions/MeshWeaver.Reactive.Assertions.csproj" />
<Project Path="src/MeshWeaver.Maps/MeshWeaver.Maps.csproj" />
<Project Path="src/MeshWeaver.OgCard/MeshWeaver.OgCard.csproj" />
<Project Path="src/MeshWeaver.Approvals/MeshWeaver.Approvals.csproj" />
<Project Path="src/MeshWeaver.Graph/MeshWeaver.Graph.csproj" />
<Project Path="src/MeshWeaver.GitSync/MeshWeaver.GitSync.csproj" />
<Project Path="src/MeshWeaver.InstanceSync/MeshWeaver.InstanceSync.csproj" />
Expand Down
19 changes: 19 additions & 0 deletions clients/react/src/i18n/strings.de.json
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,25 @@
"notifications.markAllRead": "Alle als gelesen markieren",
"notifications.allCaughtUp": "Alles erledigt.",
"notifications.unread": "Ungelesen",
"approval.approver": "Freigebende Person",
"approval.approverLabel": "Freigebende Person (Benutzer-ID)",
"approval.approverPlaceholder": "Benutzer-ObjectId eingeben...",
"approval.approvedBy": "Freigegeben von",
"approval.created": "Erstellt",
"approval.decisionDate": "Entscheidungsdatum",
"approval.due": "Fällig",
"approval.dueDateLabel": "Fälligkeitsdatum (optional, JJJJ-MM-TT)",
"approval.from": "Von",
"approval.noData": "Keine Freigabedaten.",
"approval.purpose": "Zweck",
"approval.purposePlaceholder": "Wozu wird diese Freigabe benötigt?",
"approval.rejectedBy": "Abgelehnt von",
"approval.requester": "Angefordert von",
"approval.sectionTitle": "Freigaben",
"approval.status": "Status",
"approval.statusApproved": "Freigegeben",
"approval.statusPending": "Ausstehend",
"approval.statusRejected": "Abgelehnt",
"auth.loggedInAs": "Angemeldet als:",
"auth.developerMode": "Entwicklermodus",
"auth.developerLogin": "Entwickler-Anmeldung",
Expand Down
19 changes: 19 additions & 0 deletions clients/react/src/i18n/strings.en.json
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,25 @@
"notifications.markAllRead": "Mark all read",
"notifications.allCaughtUp": "You're all caught up.",
"notifications.unread": "Unread",
"approval.approver": "Approver",
"approval.approverLabel": "Approver (User ID)",
"approval.approverPlaceholder": "Enter user ObjectId...",
"approval.approvedBy": "Approved by",
"approval.created": "Created",
"approval.decisionDate": "Decision Date",
"approval.due": "Due",
"approval.dueDateLabel": "Due Date (optional, YYYY-MM-DD)",
"approval.from": "From",
"approval.noData": "No approval data.",
"approval.purpose": "Purpose",
"approval.purposePlaceholder": "Why is this approval needed?",
"approval.rejectedBy": "Rejected by",
"approval.requester": "Requester",
"approval.sectionTitle": "Approvals",
"approval.status": "Status",
"approval.statusApproved": "Approved",
"approval.statusPending": "Pending",
"approval.statusRejected": "Rejected",
"auth.loggedInAs": "Logged in as:",
"auth.developerMode": "Developer mode",
"auth.developerLogin": "Developer login",
Expand Down
3 changes: 2 additions & 1 deletion memex/Memex.Portal.Monolith/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@
"MeshWeaver.Speech.dll",
"MeshWeaver.Markdown.Export.dll",
"MeshWeaver.Observability.dll",
"MeshWeaver.OgCard.dll"
"MeshWeaver.OgCard.dll",
"MeshWeaver.Approvals.dll"
]
}
}
1 change: 1 addition & 0 deletions memex/Memex.Portal.Shared/Memex.Portal.Shared.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
<ProjectReference Include="..\..\src\MeshWeaver.Blazor.Analysis\MeshWeaver.Blazor.Analysis.csproj" />
<ProjectReference Include="..\..\src\MeshWeaver.Blazor.GoogleMaps\MeshWeaver.Blazor.GoogleMaps.csproj" />
<ProjectReference Include="..\..\src\MeshWeaver.OgCard\MeshWeaver.OgCard.csproj" />
<ProjectReference Include="..\..\src\MeshWeaver.Approvals\MeshWeaver.Approvals.csproj" />
<ProjectReference Include="..\..\src\MeshWeaver.Blazor.OpenStreetMap\MeshWeaver.Blazor.OpenStreetMap.csproj" />
<ProjectReference Include="..\..\src\MeshWeaver.Blazor.AppleMaps\MeshWeaver.Blazor.AppleMaps.csproj" />
<ProjectReference Include="..\..\src\MeshWeaver.Blazor.Graph\MeshWeaver.Blazor.Graph.csproj" />
Expand Down
3 changes: 2 additions & 1 deletion memex/aspire/Memex.Portal.Distributed/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"MeshWeaver.Speech.dll",
"MeshWeaver.Markdown.Export.dll",
"MeshWeaver.Observability.dll",
"MeshWeaver.OgCard.dll"
"MeshWeaver.OgCard.dll",
"MeshWeaver.Approvals.dll"
]
},
// Production logging.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Reactive.Linq;
using System.Reactive.Linq;
using MeshWeaver.Data;
using MeshWeaver.Graph;
using MeshWeaver.Graph.Configuration;
using MeshWeaver.Layout;
using MeshWeaver.Layout.Composition;
Expand All @@ -9,12 +10,14 @@
using MeshWeaver.Reactive;
using MeshWeaver.ShortGuid;

namespace MeshWeaver.Graph;
namespace MeshWeaver.Approvals;

/// <summary>
/// Extension methods for adding approval support to a message hub.
/// Approvals are stored per-node as individual JSON files in _Approval/ sub-partition.
/// Follows the CommentsExtensions pattern.
/// The Approvals module's registration surface. Production installs it via
/// <c>Modules:Assemblies</c> (<see cref="ApprovalsModuleAttribute"/>); a mesh that composes it
/// explicitly — a test fixture, a bespoke host — calls <see cref="AddApprovals"/> for the
/// identical registration. Approvals are stored per-node as individual satellites in the
/// <c>_Approval</c> sub-partition (following the Comments pattern).
/// </summary>
public static class ApprovalExtensions
{
Expand All @@ -24,15 +27,23 @@ public static class ApprovalExtensions
public const string ApprovalPartition = "_Approval";

/// <summary>
/// Marker type used to detect if approvals are enabled in a hub configuration.
/// Registers approval workflows on this mesh: the <c>Approval</c> node type plus — on EVERY
/// per-node hub — the Request Approval form, the inline approvals section, the node-menu entry,
/// and the <c>ApprovalsEnabled</c> marker the markdown overview's embedded section checks.
/// Delisting the module (or not calling this) removes the Approvals UI mesh-wide while the
/// <c>Approval</c> data record and its satellite-table mapping stay platform-level.
/// </summary>
public record ApprovalsEnabled;
public static MeshBuilder AddApprovals(this MeshBuilder builder)
=> builder
.AddApprovalType()
.ConfigureDefaultNodeHub(ConfigureHub);

/// <summary>
/// Adds approval support to the message hub configuration.
/// Registers the Approval type, adds it to the data source, menu items, and layout views.
/// The per-node-hub half of the registration: type + data source + menu entry + the
/// RequestApproval/Approvals layout areas + the <see cref="ApprovalsEnabled"/> marker.
/// ONE code path shared by the module attribute and <see cref="AddApprovals"/>.
/// </summary>
public static MessageHubConfiguration AddApprovals(this MessageHubConfiguration configuration)
internal static MessageHubConfiguration ConfigureHub(MessageHubConfiguration configuration)
{
return configuration
.WithType<Approval>(nameof(Approval))
Expand All @@ -46,12 +57,6 @@ public static MessageHubConfiguration AddApprovals(this MessageHubConfiguration
.WithView("Approvals", ApprovalsView.InlineApprovals));
}

/// <summary>
/// Checks if approvals are enabled in the configuration.
/// </summary>
public static bool HasApprovals(this MessageHubConfiguration configuration)
=> configuration.Get<ApprovalsEnabled>() != null;

/// <summary>
/// Reactive menu provider that emits "Request Approval" for users with Update permission.
/// Re-emits when the viewer's effective permissions change (e.g. a role is granted at runtime),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Reactive.Linq;
using MeshWeaver.Data;
using MeshWeaver.Graph;
using MeshWeaver.Graph.Configuration;
using MeshWeaver.Layout;
using MeshWeaver.Layout.Composition;
Expand All @@ -9,11 +10,12 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace MeshWeaver.Graph;
namespace MeshWeaver.Approvals;

/// <summary>
/// Overview and Thumbnail views for individual Approval nodes.
/// Registered via ApprovalNodeType's AddApprovalViews().
/// Registered via ApprovalNodeType's AddApprovalViews(). Ships with the Approvals module:
/// delisting it removes these views mesh-wide while approval data stays platform-level.
/// </summary>
public static class ApprovalLayoutAreas
{
Expand Down Expand Up @@ -53,37 +55,38 @@ private static UiControl BuildOverview(LayoutAreaHost host, MeshNode? node, stri

container = container.WithView(MeshNodeLayoutAreas.BuildHeader(host, node, false));

if (node?.Content is not Approval approval)
var approval = node?.ContentAs<Approval>(host.Hub.JsonSerializerOptions);
if (approval is null)
{
container = container.WithView(Controls.Html(
"<p style=\"color: var(--neutral-foreground-hint); font-style: italic;\">No approval data.</p>"));
container = container.WithView(Controls.Body(host.Localize("approval.noData"))
.WithStyle("color: var(--neutral-foreground-hint); font-style: italic;"));
return container;
}

// Approval details
// Approval details — typed label rows, never hand-built HTML strings.
var details = Controls.Stack.WithWidth("100%").WithStyle("gap: 8px;");

details = details.WithView(Controls.Html(
$"<div><strong>Status:</strong> <span style=\"{GetStatusStyle(approval.Status)}\">{approval.Status}</span></div>"));
details = details.WithView(Controls.Html(
$"<div><strong>Requester:</strong> {EscapeHtml(approval.Requester)}</div>"));
details = details.WithView(Controls.Html(
$"<div><strong>Approver:</strong> {EscapeHtml(approval.Approver)}</div>"));
details = details.WithView(DetailRow(host,
"approval.status", StatusBadge(host, approval.Status)));
details = details.WithView(DetailRow(host,
"approval.requester", Controls.Body(approval.Requester)));
details = details.WithView(DetailRow(host,
"approval.approver", Controls.Body(approval.Approver)));

if (!string.IsNullOrEmpty(approval.Purpose))
details = details.WithView(Controls.Html(
$"<div><strong>Purpose:</strong> {EscapeHtml(approval.Purpose)}</div>"));
details = details.WithView(DetailRow(host,
"approval.purpose", Controls.Body(approval.Purpose)));

if (approval.DueDate.HasValue)
details = details.WithView(Controls.Html(
$"<div><strong>Due:</strong> {approval.DueDate.Value:yyyy-MM-dd}</div>"));
details = details.WithView(DetailRow(host,
"approval.due", Controls.Body($"{approval.DueDate.Value:yyyy-MM-dd}")));

if (approval.ApprovalDate.HasValue)
details = details.WithView(Controls.Html(
$"<div><strong>Decision Date:</strong> {approval.ApprovalDate.Value:yyyy-MM-dd HH:mm}</div>"));
details = details.WithView(DetailRow(host,
"approval.decisionDate", Controls.Body($"{approval.ApprovalDate.Value:yyyy-MM-dd HH:mm}")));

details = details.WithView(Controls.Html(
$"<div><strong>Created:</strong> {approval.CreatedAt:yyyy-MM-dd HH:mm}</div>"));
details = details.WithView(DetailRow(host,
"approval.created", Controls.Body($"{approval.CreatedAt:yyyy-MM-dd HH:mm}")));

container = container.WithView(details);

Expand Down Expand Up @@ -118,16 +121,18 @@ private static UiControl BuildOverview(LayoutAreaHost host, MeshNode? node, stri
// Link to primary document
if (!string.IsNullOrEmpty(approval.PrimaryNodePath))
{
container = container.WithView(Controls.Html(
$"<div style=\"margin-top: 12px;\"><a href=\"/{approval.PrimaryNodePath}\">Go to document</a></div>"));
container = container.WithView(
Controls.NavLink(host.Localize("ui.goToDocument"), $"/{approval.PrimaryNodePath}")
.WithStyle("margin-top: 12px;"));
}

return container;
}

private static void UpdateApprovalStatus(LayoutAreaHost host, MeshNode node, ApprovalStatus newStatus)
{
if (node.Content is not Approval approval)
var approval = node.ContentAs<Approval>(host.Hub.JsonSerializerOptions);
if (approval is null)
return;

var accessService = host.Hub.ServiceProvider.GetService<AccessService>();
Expand Down Expand Up @@ -221,28 +226,33 @@ public static UiControl Thumbnail(LayoutAreaHost host, RenderingContext _)
return Controls.Stack.WithView((h, c) => host.Workspace.GetMeshNodeStream()
.Select(node =>
{
if (node?.Content is Approval approval)
var approval = node?.ContentAs<Approval>(host.Hub.JsonSerializerOptions);
if (approval is not null)
{
var card = Controls.Stack.WithStyle("padding: 8px; border: 1px solid var(--neutral-stroke-rest); border-radius: 6px; gap: 4px;");
card = card.WithView(Controls.Html(
$"<div style=\"display: flex; align-items: center; gap: 8px;\">" +
$"<span style=\"{GetStatusStyle(approval.Status)}; font-size: 0.8rem; padding: 2px 8px; border-radius: 4px;\">{approval.Status}</span>" +
$"<span style=\"font-weight: 600;\">{EscapeHtml(approval.Purpose)}</span></div>"));
card = card.WithView(Controls.Stack
.WithOrientation(Orientation.Horizontal)
.WithHorizontalGap(8)
.WithStyle("align-items: center;")
.WithView(StatusBadge(host, approval.Status))
.WithView(Controls.Body(approval.Purpose).WithStyle("font-weight: 600;")));

if (approval.Status == ApprovalStatus.Approved || approval.Status == ApprovalStatus.Rejected)
{
var verb = approval.Status == ApprovalStatus.Approved ? "Approved" : "Rejected";
card = card.WithView(Controls.Html(
$"<div style=\"font-size: 0.85rem; color: var(--neutral-foreground-hint);\">{verb} by: {EscapeHtml(approval.Approver)}" +
(approval.ApprovalDate.HasValue ? $" on {approval.ApprovalDate.Value:yyyy-MM-dd}" : "") +
"</div>"));
var byLine = approval.Status == ApprovalStatus.Approved
? host.Localize("approval.approvedBy")
: host.Localize("approval.rejectedBy");
card = card.WithView(HintLine(
$"{byLine}: {approval.Approver}" +
(approval.ApprovalDate.HasValue ? $" · {approval.ApprovalDate.Value:yyyy-MM-dd}" : "")));
}
else
{
card = card.WithView(Controls.Html(
$"<div style=\"font-size: 0.85rem; color: var(--neutral-foreground-hint);\">From: {EscapeHtml(approval.Requester)}" +
(approval.DueDate.HasValue ? $" · Due: {approval.DueDate.Value:yyyy-MM-dd}" : "") +
"</div>"));
card = card.WithView(HintLine(
$"{host.Localize("approval.from")}: {approval.Requester}" +
(approval.DueDate.HasValue
? $" · {host.Localize("approval.due")}: {approval.DueDate.Value:yyyy-MM-dd}"
: "")));

if (string.Equals(approval.Approver, currentUser, StringComparison.OrdinalIgnoreCase))
{
Expand All @@ -262,14 +272,38 @@ public static UiControl Thumbnail(LayoutAreaHost host, RenderingContext _)
}));
}

private static string GetStatusStyle(ApprovalStatus status) => status switch
/// <summary>Bold label + value on one line — the typed replacement for the old HTML strings.</summary>
private static UiControl DetailRow(LayoutAreaHost host, string labelKey, UiControl value)
=> Controls.Stack
.WithOrientation(Orientation.Horizontal)
.WithHorizontalGap(6)
.WithView(Controls.Body($"{host.Localize(labelKey)}:").WithStyle("font-weight: 600;"))
.WithView(value);

private static UiControl HintLine(string text)
=> Controls.Body(text).WithStyle("font-size: 0.85rem; color: var(--neutral-foreground-hint);");

private static UiControl StatusBadge(LayoutAreaHost host, ApprovalStatus status)
{
ApprovalStatus.Pending => "color: #b8860b; background: #fff8e1",
ApprovalStatus.Approved => "color: #2e7d32; background: #e8f5e9",
ApprovalStatus.Rejected => "color: #c62828; background: #ffebee",
_ => ""
};
var (background, foreground) = status switch
{
ApprovalStatus.Pending => ("#fff8e1", "#b8860b"),
ApprovalStatus.Approved => ("#e8f5e9", "#2e7d32"),
ApprovalStatus.Rejected => ("#ffebee", "#c62828"),
_ => ("var(--neutral-fill-rest)", "var(--neutral-foreground-rest)")
};
return Controls.Badge(LocalizeStatus(host, status)) with
{
BackgroundColor = background,
Color = foreground
};
}

private static string EscapeHtml(string? text)
=> System.Net.WebUtility.HtmlEncode(text ?? "");
private static string LocalizeStatus(LayoutAreaHost host, ApprovalStatus status) => status switch
{
ApprovalStatus.Pending => host.Localize("approval.statusPending"),
ApprovalStatus.Approved => host.Localize("approval.statusApproved"),
ApprovalStatus.Rejected => host.Localize("approval.statusRejected"),
_ => status.ToString()
};
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
using MeshWeaver.Data;
using MeshWeaver.Messaging;
using MeshWeaver.Data;
using MeshWeaver.Graph;
using MeshWeaver.Graph.Security;
using MeshWeaver.Mesh;
using MeshWeaver.Mesh.Security;
using MeshWeaver.Mesh.Services;
using MeshWeaver.Messaging;
using Microsoft.Extensions.DependencyInjection;

namespace MeshWeaver.Graph.Configuration;
namespace MeshWeaver.Approvals;

/// <summary>
/// Provides configuration for Approval nodes in the graph.
/// Approval nodes are system-generated — excluded from search and create contexts.
/// Access is delegated to the MainNode (parent) via SatelliteAccessRule.
/// <para>Ships with the Approvals module (<see cref="ApprovalsModuleAttribute"/>): delisting the
/// module removes this node type's views mesh-wide, while the <c>Approval</c> content record and
/// the <c>_Approval</c> satellite-table mapping stay platform-level in MeshWeaver.Mesh.Contract.</para>
/// </summary>
public static class ApprovalNodeType
{
Expand Down
Loading
Loading