From 7a2a79e1d8c555365d1a4ca9280f5aa367c993c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= <6334612+rbuergi@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:11:09 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(layout):=20row-scoped=20actions=20?= =?UTF-8?q?=E2=80=94=20a=20click=20inside=20a=20bound=20row=20template=20c?= =?UTF-8?q?arries=20its=20row?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bound row template (BindMany / ItemTemplateControl, a DataGrid TemplateColumnControl) exists ONCE on the owner, so a button in it posted a ClickedEvent naming only the template's area and its action could not tell which row was clicked. The data-binding batches had to downgrade per-row buttons to "click the row, then act" or leave areas unconverted. - RowContext (Pointer, Index, Value, Path; NodePath()) β€” the row AS THE CLIENT RENDERED IT. - ClickedEvent.Row / BlurEvent.Row (init, non-breaking); the owner hands it to the action as UiActionContext.Row. Never re-resolved by index: a list that changed between render and click still acts on the clicked row. - ctx.RowAs() (Mesh.Contract, via ObjectAsExtensions.As) and ctx.RowPath(). - DataGridControl renders each template column's template into {grid}/Column{i}, so a cell's control and its click action are found where the client's event names them (before, the template lived only inline and a click reached the GRID's area). - RowContext registered with the layout types. Tests: RowScopedClickActionTest β€” N rows, row k acts on row k; a no-row negative control; the list changing between render and click; a grid template column (red without the RenderSelf change). Doc: GUI/DataBinding β†’ "Row-scoped actions". Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Data/GUI/DataBinding.md | 46 ++++ .../Composition/LayoutAreaHost.cs | 9 + .../DataGrid/DataGridControl.cs | 38 +++ src/MeshWeaver.Layout/ExpandRequest.cs | 14 ++ src/MeshWeaver.Layout/LayoutExtensions.cs | 1 + src/MeshWeaver.Layout/RowContext.cs | 87 +++++++ src/MeshWeaver.Layout/UiActionContext.cs | 20 +- .../UiActionContextRowExtensions.cs | 26 +++ .../RowScopedClickActionTest.cs | 221 ++++++++++++++++++ 9 files changed, 461 insertions(+), 1 deletion(-) create mode 100644 src/MeshWeaver.Layout/RowContext.cs create mode 100644 src/MeshWeaver.Mesh.Contract/UiActionContextRowExtensions.cs create mode 100644 test/MeshWeaver.Layout.Test/RowScopedClickActionTest.cs diff --git a/src/MeshWeaver.Documentation/Data/GUI/DataBinding.md b/src/MeshWeaver.Documentation/Data/GUI/DataBinding.md index bccebc7bf2..e9cd5cbc52 100644 --- a/src/MeshWeaver.Documentation/Data/GUI/DataBinding.md +++ b/src/MeshWeaver.Documentation/Data/GUI/DataBinding.md @@ -512,6 +512,52 @@ sequenceDiagram --- +# Row-scoped actions + +A bound row template is declared ONCE and rendered by the client once per row: `BindMany` (an `ItemTemplateControl`) and a data grid's `TemplateColumnControl`. On the owner the template's controls exist once, at the template's area, so a button in it cannot say which row it is by its area. The click carries the row instead: the client stamps the row it rendered on the `ClickedEvent` (`ClickedEvent.Row`, a `RowContext`), and the action reads it from `UiActionContext.Row`. + +```csharp +// A list β€” one Archive button per row, one action for all of them. +mail.BindMany("mail", m => Controls.Stack + .WithView(Controls.Label(m.Subject)) + .WithView(Controls.Button("πŸ—ƒοΈ").WithClickAction(ctx => Archive(ctx)))); + +static Task Archive(UiActionContext ctx) +{ + var row = ctx.RowAs(); // the row as the client rendered it (MeshWeaver.Mesh) + var path = ctx.RowPath(); // its node path, for a node row (a `path` property) + // … write as the clicking user; the write's own access check is the guard … + return Task.CompletedTask; +} + +// A grid (any bound DataGridControl) β€” the template column's button acts on its row. +grid.WithColumn(new PropertyColumnControl { Property = "code" }) + .WithColumn(new TemplateColumnControl( + Controls.Button("➑️").WithClickAction(ctx => Open(ctx)))); +``` + +What `RowContext` holds: + +| Field | Meaning | +|---|---| +| `Value` | The row's value as the client rendered it β€” JSON. Read it with `ctx.RowAs()`, never a cast. | +| `NodePath()` / `ctx.RowPath()` | `Path` when the client set it, else the value's own `path` property. Null for a row that is not a node. | +| `Pointer` | The row's data context (`/data/"mail"/3`) for a `BindMany` row; null for a grid row, which the client sorts and pages. | +| `Index` | The row's position when it was rendered. Diagnostic only. | + +The rules: + +- 🚨 **The row is the one the person CLICKED, never a position re-read at click time.** A list that changed between the render and the click (a row added above, one removed) would hand an index-based action whatever row moved into that slot β€” the wrong mail archived, the wrong token revoked. `Value` is what the person saw, so it is the identity; never re-resolve the row through `Pointer` or `Index`. +- 🚨 **The row is USER INPUT**, like any click payload. The action writes as the clicking user and the write's own access check decides whether the user may act on that row; a field of the row is never proof of anything. +- **Inside a `BindMany` expression a click action is an expression-bodied lambda or a method group** (`ctx => Archive(ctx)`), because the template is an expression tree; put the body in a method. +- **A grid template column's template is rendered into its own sub-area** (`DataGridControl.TemplateColumnArea(i)`, i.e. `{grid}/Column{i}`), which is where its controls β€” and their click actions β€” are found. Do not also give the grid a row-click action (`DataGridCellClick`) for the same cells: a click on the button is a click on its cell too. +- **`MeshSearch` rows need none of this.** Each result renders through the node's OWN item area (`WithItemArea(…)`), so a button there lives on that node's hub, which already knows its path. +- A `BlurEvent` carries the row the same way; inputs in a row need nothing extra, since they are bound by pointer to the row they edit. + +Where it is wired: core `MeshWeaver.Layout` (`RowContext`, `ClickedEvent.Row`, `UiActionContext.Row`, `DataGridControl.RenderSelf`) and `MeshWeaver.Mesh.Contract` (`RowAs`); the Blazor views in MeshWeaver.Plugins cascade the row (`ItemTemplate`, `DataGridView`) and `BlazorView` stamps it on every click and blur. Pinned by `test/MeshWeaver.Layout.Test/RowScopedClickActionTest` (N rows, row k acts on row k; a no-row negative control; the list changing between render and click; a grid template column) and, for the client half, Plugins' `RowScopedActionsFromViewsTest`. + +--- + # Two-Way Sync Details Changes travel as JSON Patch (RFC 6902) for efficient delta updates: diff --git a/src/MeshWeaver.Layout/Composition/LayoutAreaHost.cs b/src/MeshWeaver.Layout/Composition/LayoutAreaHost.cs index 02690ed07b..ac95a72d6b 100644 --- a/src/MeshWeaver.Layout/Composition/LayoutAreaHost.cs +++ b/src/MeshWeaver.Layout/Composition/LayoutAreaHost.cs @@ -650,8 +650,14 @@ private IMessageDelivery OnClick(IMessageDelivery request) IObservable completion; try { + // Row: the row a row-scoped control was clicked in, AS THE CLIENT RENDERED IT β€” never + // re-resolved here by index, which a list changed since the render would answer with + // another row (Doc/GUI/DataBinding β†’ "Row-scoped actions"). completion = control.ClickAction.Invoke( new(request.Message.Area, request.Message.Payload ?? new object(), Hub, this) + { + Row = request.Message.Row + } ) ?? Observable.Return(System.Reactive.Unit.Default); } catch (Exception ex) @@ -708,6 +714,9 @@ private IMessageDelivery OnBlur(IMessageDelivery request) { blurAction.Invoke( new(request.Message.Area, request.Message.Payload ?? new object(), Hub, this) + { + Row = request.Message.Row + } ); } catch (Exception ex) diff --git a/src/MeshWeaver.Layout/DataGrid/DataGridControl.cs b/src/MeshWeaver.Layout/DataGrid/DataGridControl.cs index e47c942c40..63b0519590 100644 --- a/src/MeshWeaver.Layout/DataGrid/DataGridControl.cs +++ b/src/MeshWeaver.Layout/DataGrid/DataGridControl.cs @@ -1,5 +1,6 @@ ο»Ώusing System.Collections.Immutable; using MeshWeaver.Layout.Composition; +using MeshWeaver.Data; namespace MeshWeaver.Layout.DataGrid; @@ -28,6 +29,43 @@ protected override DataGridControl PrepareRendering(RenderingContext context) Style = Style ?? $"min-width: {Columns.Count * 120}px" }; + /// + /// The sub-area, relative to the grid's own, under which the template of the + /// at is rendered. + /// + /// The zero-based position of the column in . + /// The relative area name (Column{index}). + public static string TemplateColumnArea(int columnIndex) => $"Column{columnIndex}"; + + /// + /// Writes the grid, then renders the template of every into + /// its own sub-area (), the way + /// renders its row view. + /// + /// 🚨 Without it a template column's control existed only INLINE in the grid: the owner had + /// no area holding it, so a click inside a cell posted the GRID's area and ran the grid's click + /// action (or nothing), and a container template's children had no areas to resolve. Rendered as + /// an area, the template's controls β€” and their click actions β€” are found where the client's + /// ClickedEvent names them, and the event's says which row + /// (Doc/GUI/DataBinding β†’ "Row-scoped actions"). + /// + /// The layout area host. + /// The rendering context of the grid's area. + /// The entity store to update. + /// The store with the grid and its column templates written. + protected override EntityStoreAndUpdates RenderSelf(LayoutAreaHost host, RenderingContext context, EntityStore store) + { + var ret = base.RenderSelf(host, context, store); + for (var i = 0; i < Columns.Count; i++) + { + if (Columns[i] is not TemplateColumnControl { Template: { } template }) + continue; + var rendered = host.RenderArea(GetContextForArea(context, TemplateColumnArea(i)), template, ret.Store); + ret = rendered with { Updates = ret.Updates.Concat(rendered.Updates) }; + } + return ret; + } + /// /// Returns a copy of the control with the given appended to the column list. /// diff --git a/src/MeshWeaver.Layout/ExpandRequest.cs b/src/MeshWeaver.Layout/ExpandRequest.cs index edb49db933..d472b4a43f 100644 --- a/src/MeshWeaver.Layout/ExpandRequest.cs +++ b/src/MeshWeaver.Layout/ExpandRequest.cs @@ -16,6 +16,14 @@ public record ClickedEvent(string Area, string StreamId) : StreamMessage(StreamI /// public object? Payload { get; init; } + /// + /// The row the clicked control belongs to, when it is declared inside a bound row template + /// (a BindMany list, a data grid's template column). The client fills it with the row it + /// rendered; the owner hands it to the action as . Null for a + /// control outside any row. See . + /// + public RowContext? Row { get; init; } + /// string IUserAction.ActionArea => Area; } @@ -32,6 +40,12 @@ public record BlurEvent(string Area, string StreamId) : StreamMessage(StreamId), /// public object? Payload { get; init; } + /// + /// The row the control belongs to, when it is declared inside a bound row template β€” the same + /// contract as . + /// + public RowContext? Row { get; init; } + /// string IUserAction.ActionArea => Area; } diff --git a/src/MeshWeaver.Layout/LayoutExtensions.cs b/src/MeshWeaver.Layout/LayoutExtensions.cs index 8e7c790120..671fc754b7 100644 --- a/src/MeshWeaver.Layout/LayoutExtensions.cs +++ b/src/MeshWeaver.Layout/LayoutExtensions.cs @@ -194,6 +194,7 @@ this MessageHubConfiguration configuration typeof(LayoutAreasResponse), typeof(UserActionAccepted), typeof(DataGridCellClick), + typeof(RowContext), // carried on ClickedEvent / BlurEvent, not a StreamMessage itself // Non-IUiControl content/config records serialised INSIDE control state (so the reflection // sweep above misses them) β€” they came back untyped on sync hubs and churned the layout: typeof(MeshWeaver.Layout.LayoutAreaDefinition), diff --git a/src/MeshWeaver.Layout/RowContext.cs b/src/MeshWeaver.Layout/RowContext.cs new file mode 100644 index 0000000000..17d27316a9 --- /dev/null +++ b/src/MeshWeaver.Layout/RowContext.cs @@ -0,0 +1,87 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace MeshWeaver.Layout; + +/// +/// The ROW a user action came from, when the control that raised it is declared inside a bound +/// row template: a BindMany list () or a data grid's +/// . The template is declared ONCE and rendered once +/// per row by the client, so the control's area names the TEMPLATE, never the row; this record +/// is what tells a row-scoped action which row was clicked (Doc/GUI/DataBinding β†’ "Row-scoped +/// actions"). +/// +/// 🚨 It is the row AS THE CLIENT RENDERED IT β€” the value the person saw when they clicked β€” +/// not a position the owner re-resolves. A list that changed between the render and the click +/// (a row added above, a row removed) therefore still acts on the row that was clicked: an index +/// re-read at click time would hand the action whatever row has since moved into that slot. +/// and are carried for diagnostics and for binding +/// relative to the row; the identity is (and for a node +/// row). +/// +/// 🚨 It is USER INPUT, exactly like any other click payload. An action authorizes the +/// write it makes β€” which it does by making it as the clicking user β€” and never treats a field of +/// the row as proof that the user may act on it. +/// +public record RowContext +{ + /// + /// The row's data context β€” the JSON pointer the client bound the row's template against + /// (e.g. /data/"rows"/3). Null where the row has no pointer of its own (a data grid's + /// rows are sorted and paged on the client). + /// + public string? Pointer { get; init; } + + /// The row's position in the rendered collection, when it has one. Diagnostic only β€” + /// never re-resolve the row by it. + public int? Index { get; init; } + + /// + /// The row's value as the client rendered it. Arrives as JSON ( or + /// ) β€” read it with ctx.RowAs<T>(), never with a cast. + /// + public object? Value { get; init; } + + /// + /// The mesh path of the row, when the row is a node. A client may set it explicitly; when it + /// does not, reads the row value's own path property. + /// + public string? Path { get; init; } + + /// + /// The row's node path: when set, otherwise the string path property of + /// (a serialized MeshNode, or any row record that carries one); + /// null when the row is not a node. + /// + /// The node path, or null. + public string? NodePath() + { + if (!string.IsNullOrEmpty(Path)) + return Path; + return Value switch + { + JsonElement { ValueKind: JsonValueKind.Object } element => ReadPath(element), + JsonObject node => ReadPath(node), + null => null, + var typed => typed.GetType().GetProperty("Path")?.GetValue(typed) as string, + }; + } + + private static string? ReadPath(JsonElement element) + { + foreach (var property in element.EnumerateObject()) + if (string.Equals(property.Name, "path", StringComparison.OrdinalIgnoreCase) + && property.Value.ValueKind == JsonValueKind.String) + return property.Value.GetString(); + return null; + } + + private static string? ReadPath(JsonObject node) + { + foreach (var (name, value) in node) + if (string.Equals(name, "path", StringComparison.OrdinalIgnoreCase) + && value is JsonValue v && v.TryGetValue(out var path)) + return path; + return null; + } +} diff --git a/src/MeshWeaver.Layout/UiActionContext.cs b/src/MeshWeaver.Layout/UiActionContext.cs index adb1fc25ab..45725aa596 100644 --- a/src/MeshWeaver.Layout/UiActionContext.cs +++ b/src/MeshWeaver.Layout/UiActionContext.cs @@ -10,13 +10,31 @@ namespace MeshWeaver.Layout; /// The payload associated with the UI action. /// The message hub for handling messages related to the UI action. /// The layout area host associated with the UI action. -public record UiActionContext(string Area, object? Payload, IMessageHub Hub, LayoutAreaHost Host); +public record UiActionContext(string Area, object? Payload, IMessageHub Hub, LayoutAreaHost Host) +{ + /// + /// The row this action was raised from, when its control is declared inside a bound row template + /// (a BindMany list, a data grid's template column) β€” the row as the client rendered it. + /// Null for a control outside any row. Read the value with RowAs<T>() and a node + /// row's path with ; see + /// for why it is the rendered row and never a re-resolved index. + /// + public RowContext? Row { get; init; } +} /// /// Extension methods for UiActionContext. /// public static class UiActionContextExtensions { + /// + /// The mesh path of the row this action was raised from (), or + /// null when the control is not in a row or the row is not a node. + /// + /// The UI action context. + /// The row's node path, or null. + public static string? RowPath(this UiActionContext context) => context.Row?.NodePath(); + /// /// Navigates to the specified URI by posting a NavigationRequest to the portal. /// Safe to call from click handlers and other UI action contexts. diff --git a/src/MeshWeaver.Mesh.Contract/UiActionContextRowExtensions.cs b/src/MeshWeaver.Mesh.Contract/UiActionContextRowExtensions.cs new file mode 100644 index 0000000000..24682a8205 --- /dev/null +++ b/src/MeshWeaver.Mesh.Contract/UiActionContextRowExtensions.cs @@ -0,0 +1,26 @@ +using MeshWeaver.Layout; +using Microsoft.Extensions.Logging; + +namespace MeshWeaver.Mesh; + +/// +/// Reads the row a row-scoped action was raised from (Doc/GUI/DataBinding β†’ "Row-scoped actions"). +/// Lives next to because the row arrives as JSON and is read the +/// one way an object payload is read on the mesh. +/// +public static class UiActionContextRowExtensions +{ + /// + /// The row this action was raised from, as β€” the row as the client + /// rendered it (), converted with + /// against the owning hub's serializer options. Null when the control is not inside a bound row, + /// or when the row does not convert (logged when is given). + /// + /// The row record the template was bound to. + /// The UI action context. + /// Optional; names an unconvertible row in the log. + /// The row, or null. + public static T? RowAs(this UiActionContext context, ILogger? logger = null) + where T : class + => context.Row?.Value.As(context.Hub.JsonSerializerOptions, logger, $"row of {context.Area}"); +} diff --git a/test/MeshWeaver.Layout.Test/RowScopedClickActionTest.cs b/test/MeshWeaver.Layout.Test/RowScopedClickActionTest.cs new file mode 100644 index 0000000000..1add6fde26 --- /dev/null +++ b/test/MeshWeaver.Layout.Test/RowScopedClickActionTest.cs @@ -0,0 +1,221 @@ +using System.Collections.Immutable; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using System.Text.Json; +using System.Text.Json.Nodes; +using MeshWeaver.Data; +using MeshWeaver.Fixture; +using MeshWeaver.Layout.DataGrid; +using MeshWeaver.Mesh; +using MeshWeaver.Messaging; +using Xunit; + +namespace MeshWeaver.Layout.Test; + +/// +/// Pins the owner half of ROW-SCOPED ACTIONS (Doc/GUI/DataBinding β†’ "Row-scoped actions"): a button +/// declared inside a bound row template β€” a BindMany list or a data grid's template column β€” is +/// rendered by the client once per row, but exists on the owner ONCE, at the template's area. Before +/// this, its named only that area, so every row's button ran the same +/// action with nothing saying which row, and the data-binding batches had to downgrade per-row +/// buttons to "click the row, then act". The event now carries the row as the client rendered it +/// (), and the action reads it from . +/// +/// The client half β€” the Blazor views cascading the row and stamping it on the event β€” is +/// pinned in MeshWeaver.Plugins (RowScopedActionsFromViewsTest). This suite posts exactly +/// what that client posts, read off the client's own mirror of the layout stream. +/// +public class RowScopedClickActionTest(ITestOutputHelper output) : HubTestBase(output) +{ + private const string ListArea = "RowList"; + private const string ListDataId = "fruits"; + private const string GridArea = "RowGrid"; + + /// The row record the templates bind to. A node-like so the node-row + /// accessor is exercised too. + public record Fruit(string Id, string Label, string Path); + + private static Fruit F(string id) => new(id, $"Fruit {id}", $"Orchard/{id}"); + + private static readonly ImmutableList Initial = [F("a"), F("b"), F("c"), F("d")]; + + private readonly BehaviorSubject> fruits = new(Initial); + + /// Every row-scoped click the owner ran, in order. + private readonly Subject clicks = new(); + + private Task Record(UiActionContext ctx) + { + clicks.OnNext(ctx); + return Task.CompletedTask; + } + + /// + protected override MessageHubConfiguration ConfigureHost(MessageHubConfiguration configuration) + => base.ConfigureHost(configuration) + .WithTypes(typeof(Fruit)) + .AddLayout(layout => layout + // A bound list: ONE template, one button per row on the client. + .WithView(ListArea, fruits.BindMany(ListDataId, + fruit => Controls.Button(fruit.Label).WithClickAction(ctx => Record(ctx)))) + // A data grid whose template column holds the per-row button. + .WithView(GridArea, new DataGridControl(Initial) + .WithColumn(new PropertyColumnControl { Property = "label" }) + .WithColumn(new TemplateColumnControl( + Controls.Button("Open").WithClickAction(ctx => Record(ctx)))))); + + /// + protected override MessageHubConfiguration ConfigureClient(MessageHubConfiguration configuration) + => base.ConfigureClient(configuration).AddLayoutClient(); + + /// + /// N rows, one button each: clicking row k runs the action with row k β€” its value, its pointer + /// and its node path β€” for every k. The rows are distinct, so an action handed the wrong row + /// (an off-by-one, the first row, the last one) fails here. + /// + [HubFact] + public async Task EachRowsButtonActsOnItsOwnRow() + { + var stream = await OpenList(); + var rendered = await RenderedRows(stream, Initial.Count); + + for (var k = 0; k < Initial.Count; k++) + { + var ctx = await Click(stream, ListTemplateArea, RowAsTheClientRendersIt(rendered, k)); + + ctx.RowAs().Should().Be(Initial[k], $"the button in row {k} was clicked"); + ctx.RowPath().Should().Be(Initial[k].Path, "a node row names its node"); + ctx.Row!.Pointer.Should().Be($"{ListPointer}/{k}"); + ctx.Row.Index.Should().Be(k); + } + } + + /// + /// NEGATIVE CONTROL β€” the shape before this mechanism: the same button, clicked with no row on the + /// event, still runs its action, and the action CANNOT tell which row it came from. So what the + /// test above asserts comes from the row on the event and from nothing else on the owner. + /// + [HubFact] + public async Task AClickWithoutARowCannotTellWhichRowItCameFrom() + { + var stream = await OpenList(); + await RenderedRows(stream, Initial.Count); + + var ctx = await Click(stream, ListTemplateArea, row: null); + + ctx.Row.Should().BeNull("the template exists once on the owner; nothing there names a row"); + ctx.RowAs().Should().BeNull(); + ctx.RowPath().Should().BeNull(); + } + + /// + /// 🚨 The list changes between the render and the click β€” a row inserted above, another removed β€” + /// and the click still acts on the row the person clicked. The owner never re-resolves the row by + /// its index: re-read now, index 2 holds a DIFFERENT fruit, and an action that trusted it would + /// archive, revoke or delete the wrong row. A fresh click on the re-rendered list acts on what is + /// now there. + /// + [HubFact] + public async Task ARowClickedAfterTheListChangedStillActsOnTheRowThatWasClicked() + { + var stream = await OpenList(); + var before = await RenderedRows(stream, Initial.Count); + var clickedRow = RowAsTheClientRendersIt(before, 2); // "c", as the person saw it + + // Insert two rows above and remove the first: index 2 now holds "b". + ImmutableList changed = [F("y"), F("z"), F("b"), F("c"), F("d")]; + fruits.OnNext(changed); + var after = await RenderedRows(stream, changed.Count); + after[2].GetProperty("id").GetString().Should().Be("b", + "premise: the slot the person clicked now holds another row"); + + var stale = await Click(stream, ListTemplateArea, clickedRow); + stale.RowAs().Should().Be(F("c"), + "the action must act on the row that was clicked, not on whatever moved into its slot"); + stale.RowPath().Should().Be("Orchard/c"); + + var fresh = await Click(stream, ListTemplateArea, RowAsTheClientRendersIt(after, 2)); + fresh.RowAs().Should().Be(F("b"), "a click on the re-rendered row acts on what it shows"); + } + + /// + /// A data grid's template column: its template is rendered into its own sub-area, so the button's + /// click action is found there (before, it existed only inline in the grid and a click inside a + /// cell reached the GRID's area), and the row arrives as the grid client renders it β€” a JSON object + /// with no pointer, since a grid sorts and pages its rows on the client. + /// + [HubFact] + public async Task ATemplateColumnButtonActsOnItsGridRow() + { + var stream = GetClient().GetWorkspace().GetRemoteStream( + CreateHostAddress(), new LayoutAreaReference(GridArea)); + var columnArea = $"{GridArea}/{DataGridControl.TemplateColumnArea(1)}"; + + await stream.GetControlStream(columnArea).Should().Within(10.Seconds()).Match( + control => control is ButtonControl, + "the template column's button is rendered into its own area, where a click names it", + TestContext.Current.CancellationToken); + + for (var k = 0; k < Initial.Count; k++) + { + var row = new RowContext + { + Value = JsonSerializer.SerializeToNode(Initial[k], GetClient().JsonSerializerOptions)!.AsObject() + }; + var ctx = await Click(stream, columnArea, row); + ctx.RowAs().Should().Be(Initial[k], $"the Open button in grid row {k} was clicked"); + ctx.RowPath().Should().Be(Initial[k].Path); + } + } + + // ── Harness ───────────────────────────────────────────────────────────────────────────────── + + private static string ListPointer => LayoutAreaReference.GetDataPointer(ListDataId); + + private static string ListTemplateArea => $"{ListArea}/{ItemTemplateControl.ViewArea}"; + + /// What the client posts for row : the row's pointer and the value + /// it rendered (the Blazor ItemTemplate cascades exactly this to the row's views). + private static RowContext RowAsTheClientRendersIt(ImmutableArray rows, int k) => new() + { + Pointer = $"{ListPointer}/{k}", + Index = k, + Value = rows[k].Clone(), + }; + + private async Task> OpenList() + { + var stream = GetClient().GetWorkspace().GetRemoteStream( + CreateHostAddress(), new LayoutAreaReference(ListArea)); + await stream.GetControlStream(ListTemplateArea).Should().Within(10.Seconds()).Match( + control => control is ButtonControl, + "the row template is rendered once, at the template's area", + TestContext.Current.CancellationToken); + return stream; + } + + /// The rows as the client's mirror holds them, once it holds . + private static async Task> RenderedRows( + ISynchronizationStream stream, int count) + { + var rows = await stream.GetDataStream(new JsonPointerReference(ListPointer)) + .Should().Within(10.Seconds()).Match( + rows => rows.ValueKind == JsonValueKind.Array && rows.GetArrayLength() == count, + $"the client mirror holds the {count} rows it renders", + TestContext.Current.CancellationToken); + return [.. rows.EnumerateArray().Select(r => r.Clone())]; + } + + /// Submits a click the way a client does and returns the context the action ran with. + private async Task Click( + ISynchronizationStream stream, string area, RowContext? row) + { + var ran = new ReplaySubject(1); + using var _ = clicks.Subscribe(ran); + stream.SubmitUserAction( + new ClickedEvent(area, stream.StreamId) { Row = row }, + actingUser: null, onRefused: null, onAccepted: null); + return (await ran.Should().Within(10.Seconds()).Emit( + "the clicked button's action runs on the owner", TestContext.Current.CancellationToken))!; + } +} From a935c90896b9b29497f34fbd257e686ba5b9ce9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= <6334612+rbuergi@users.noreply.github.com> Date: Thu, 1 Oct 2026 20:22:47 +0200 Subject: [PATCH 2/3] feat(samples): the Todo areas are templates with row-scoped actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All seven Todo areas (AllItems, TodosByCategory, Summary, Planning, MyTasks, Backlog, TodaysFocus) waited for the todos on the hub and baked one menu per todo, the todo captured in each closure. Each is now ONE page rendered at once β€” title, "Add New Todo", one bound list β€” fed by a pure projection (TodoProjections) into rows of one record, TodoEntry: a heading (section title, summary line, empty state; optionally a group action) or a todo (card + action menu, or the assignment menu). Parts a row lacks, and secondary actions its status does not offer, are hidden by bound styles, so the template never depends on data. Every button is declared once and is ROW-SCOPED (#5955): TodoRowActions reads ctx.RowAs() and acts on the row's Item, or on its Group exactly as rendered β€” what the closure captured now travels with the click. Writes stay DataChangeRequests to the hub owning the data source (todos are data-source entities, not mesh nodes). The hand-built
HTML and the HTML textarea are gone; every string is localized (todo.* en+de). - TodoRowScopedActionsTest (11): every area is a template; the page renders with a feed that never answers; row k's Start / Delete / Assign act on todo k for every k, also after the list changed; a heading's group action acts on its group; no-row negative control; the projection's per-status actions. Mutation control: an action that ignores the row goes red. - Ratchet: Todo's 7 bake units removed; TotalBudget 73 -> 66. - DataBinding.md: the Todo sample as the worked example of row-scoped templates. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../MeshWeaver.Todo/LayoutAreas/TodoEntry.cs | 144 ++ .../LayoutAreas/TodoLayoutAreas.cs | 1841 ++--------------- .../LayoutAreas/TodoProjections.cs | 412 ++++ .../LayoutAreas/TodoRowActions.cs | 243 +++ .../MeshWeaver.Todo/MeshWeaver.Todo.csproj | 5 + .../TodoApplicationExtensions.cs | 3 +- .../Data/GUI/DataBinding.md | 2 + .../Localization/strings.de.json | 88 +- .../Localization/strings.en.json | 88 +- test/LayoutAreaDataBakeSites.allow | 1 - .../LayoutAreaDataBakeRatchetGuard.cs | 2 +- .../MeshWeaver.Layout.Test.csproj | 3 + .../TodoRowScopedActionsTest.cs | 359 ++++ 13 files changed, 1495 insertions(+), 1696 deletions(-) create mode 100644 samples/Todo/MeshWeaver.Todo/LayoutAreas/TodoEntry.cs create mode 100644 samples/Todo/MeshWeaver.Todo/LayoutAreas/TodoProjections.cs create mode 100644 samples/Todo/MeshWeaver.Todo/LayoutAreas/TodoRowActions.cs create mode 100644 test/MeshWeaver.Layout.Test/TodoRowScopedActionsTest.cs diff --git a/samples/Todo/MeshWeaver.Todo/LayoutAreas/TodoEntry.cs b/samples/Todo/MeshWeaver.Todo/LayoutAreas/TodoEntry.cs new file mode 100644 index 0000000000..3375fea2b0 --- /dev/null +++ b/samples/Todo/MeshWeaver.Todo/LayoutAreas/TodoEntry.cs @@ -0,0 +1,144 @@ +using System.Collections.Immutable; +using MeshWeaver.Todo.Domain; + +namespace MeshWeaver.Todo.LayoutAreas; + +/// +/// One row of a Todo view's bound list β€” the PROJECTION every Todo area feeds into its template +/// (Doc/GUI/DataBinding β†’ "Templates first, data later"). A row is either a heading line (a section +/// title, a summary line, an empty state), optionally with a group action, or a todo item with its +/// per-item actions; the parts a row does not have are hidden by their bound style, so the template's +/// shape never depends on data. +/// +/// Its actions are ROW-SCOPED (Doc/GUI/DataBinding β†’ "Row-scoped actions"): the template's +/// buttons exist once, and each click reads the row it was raised from with +/// ctx.RowAs<TodoEntry>() β€” the row as the person saw it. A group action therefore acts +/// on exactly as it was rendered, and an item action on . +/// +public record TodoEntry +{ + /// A key unique within the list (diagnostics and test identity). + public string Key { get; init; } = string.Empty; + + /// Markdown for the heading part; empty for an item row. + public string Heading { get; init; } = string.Empty; + + /// The heading part's style β€” on an item row. + public string HeadingStyle { get; init; } = TodoStyles.Hidden; + + /// The group action's label. + public string GroupTitle { get; init; } = string.Empty; + + /// The group action's icon. + public string GroupIcon { get; init; } = string.Empty; + + /// The group action's style β€” hidden when the row has no group action. + public string GroupStyle { get; init; } = TodoStyles.Hidden; + + /// What the group action does β€” a value. + public string GroupAction { get; init; } = string.Empty; + + /// The todos the group action acts on, as rendered. + public ImmutableList Group { get; init; } = []; + + /// The person a assigns to. + public string? Person { get; init; } + + /// The todo of an item row, as rendered; null on a heading row. + public TodoItem? Item { get; init; } + + /// The item card's style β€” on a heading row. + public string ItemStyle { get; init; } = TodoStyles.Hidden; + + /// The status glyph shown on the card. + public string Icon { get; init; } = string.Empty; + + /// The card's markdown (title, category, responsible person, due date, description). + public string Content { get; init; } = string.Empty; + + /// The item action menu's style β€” hidden on heading rows and on assignment rows. + public string ActionsStyle { get; init; } = TodoStyles.Hidden; + + /// The primary action's label (depends on the item's status). + public string PrimaryTitle { get; init; } = string.Empty; + + /// The primary action's icon. + public string PrimaryIcon { get; init; } = string.Empty; + + /// Style of the "Complete" secondary action (shown while pending). + public string CompleteStyle { get; init; } = TodoStyles.Hidden; + + /// Style of the "Cancel" secondary action (shown while pending or in progress). + public string CancelStyle { get; init; } = TodoStyles.Hidden; + + /// Style of the "Pause" secondary action (shown while in progress). + public string PauseStyle { get; init; } = TodoStyles.Hidden; + + /// Style of the "Send Reminder" secondary action (shown while overdue and open). + public string ReminderStyle { get; init; } = TodoStyles.Hidden; + + /// The assignment menu's style β€” shown on rows that offer assignment instead of actions. + public string AssignStyle { get; init; } = TodoStyles.Hidden; +} + +/// +/// What a heading row's group action does. An OPEN set of string constants +/// (Doc/Architecture/OpenVocabulariesAsStringConstants): an unknown value is logged, never defaulted. +/// +public static class TodoGroupActions +{ + /// Moves the group to In Progress. + public const string Start = "start"; + + /// Moves the group to Completed. + public const string Complete = "complete"; + + /// Deletes the group (archive / delete). + public const string Delete = "delete"; + + /// Assigns the group round-robin over the team. + public const string AutoAssign = "autoAssign"; + + /// Assigns the group's first todo to . + public const string AssignFirst = "assignFirst"; +} + +/// The inline styles the Todo template binds β€” a part is shown with its style, or hidden. +public static class TodoStyles +{ + /// Hides a part the row does not have. + public const string Hidden = "display: none;"; + + /// A menu button aligned to the right of its grid cell. + public const string Menu = "display: flex; align-items: center; justify-content: flex-end; height: 100%; padding-top: 20px;"; + + /// A secondary menu entry that is offered. + public const string Shown = ""; + + /// The todo card. + public const string Card = "display: flex; flex-direction: row; align-items: flex-start; gap: 12px; padding: 12px; margin-bottom: 8px; " + + "border: 1px solid var(--color-border-default); border-radius: 6px; background-color: var(--color-canvas-subtle); " + + "box-shadow: 0 1px 2px var(--color-shadow-small);"; + + /// A todo card indented under its section. + public const string IndentedCard = Card + " margin-left: 10px;"; + + /// A compact one-line todo (the planning backlog). + public const string Line = "padding-left: 20px; margin-bottom: 5px;"; + + /// The cell holding a row's action menu. + public const string Actions = "display: flex; justify-content: center; align-items: center; padding: 12px; margin-bottom: 8px;"; + + /// A section heading in . + public static string Heading(string color, string marginTop = "16px") + => $"margin-top: {marginTop}; margin-bottom: 6px; color: {color};"; + + /// An indented statistics line. + public const string Stat = "padding-left: 20px; margin-bottom: 5px;"; + + /// A muted hint. + public const string Muted = "color: var(--color-fg-muted);"; + + /// A centred "all caught up" message. + public const string Success = "color: var(--color-success-fg); text-align: center; margin-top: 40px;"; +} diff --git a/samples/Todo/MeshWeaver.Todo/LayoutAreas/TodoLayoutAreas.cs b/samples/Todo/MeshWeaver.Todo/LayoutAreas/TodoLayoutAreas.cs index bdb922529b..908c26f9a3 100644 --- a/samples/Todo/MeshWeaver.Todo/LayoutAreas/TodoLayoutAreas.cs +++ b/samples/Todo/MeshWeaver.Todo/LayoutAreas/TodoLayoutAreas.cs @@ -1,1774 +1,233 @@ -ο»Ώusing System.ComponentModel.DataAnnotations; +using System.Collections.Immutable; +using System.ComponentModel.DataAnnotations; +using System.Globalization; using System.Reactive.Linq; -using System.Text; -using MeshWeaver.Data; using MeshWeaver.Layout; using MeshWeaver.Layout.Composition; -using MeshWeaver.ShortGuid; using MeshWeaver.Todo.Domain; namespace MeshWeaver.Todo.LayoutAreas; /// -/// Layout areas for the Todo application +/// Layout areas for the Todo application. +/// +/// Every area is a TEMPLATE (Doc/GUI/DataBinding β†’ "Templates first, data later"): it returns +/// its whole control tree on the first render β€” a title, the "Add New Todo" action, and ONE bound +/// list β€” and the todos arrive later, as rows () a pure projection computes +/// from the data source. Nothing on the hub waits for the todos before the page renders. +/// +/// Each row's buttons are declared once in the row template and are ROW-SCOPED +/// (Doc/GUI/DataBinding β†’ "Row-scoped actions"): a click carries the row it was raised from, so +/// clicking "Start" in row k starts the todo shown in row k β€” also after the list has changed +/// underneath (). /// public static class TodoLayoutAreas { + /// The data id of every Todo area's bound row list. + public const string EntriesId = "todoEntries"; + /// - /// Creates a AllItems layout area that subscribes to a stream of todo items - /// and displays them in an interactive format with action buttons + /// All todos, grouped by status, with per-item and per-group actions. /// /// The layout area host /// The rendering context - /// An observable UI control that updates when todo items change + /// The area's template; its rows bind to the live todo list. [Display(GroupName = "2. Team Overview", Order = 2)] - public static IObservable AllItems(LayoutAreaHost host, RenderingContext context) - { - _ = context; // Unused parameter but required by interface - // Subscribe to the stream of TodoItem entities from the data source - return host.Workspace - .GetStream()! - .Select(todoItems => CreateInteractiveTodoListStack(todoItems!, host)) - .StartWith(Controls.Markdown("# Todo List\n\n*Loading todo items...*")); - } + public static UiControl AllItems(LayoutAreaHost host, RenderingContext context) + => Page(host, host.Localize("todo.allItems.title"), Typography.H4, withAdd: true, + Feed(host, TodoProjections.AllItems)); /// - /// Creates a TodosByCategory layout area that groups todos by category with interactive controls + /// Todos grouped by category, with category-level and item-level actions. /// /// The layout area host /// The rendering context - /// An observable UI control showing todos grouped by category with action buttons + /// The area's template; its rows bind to the live todo list. [Display(GroupName = "2. Team Overview", Order = 3)] - public static IObservable TodosByCategory(LayoutAreaHost host, RenderingContext context) - { - _ = context; // Unused parameter but required by interface - return host.Workspace - .GetStream()! - .Select(todoItems => CreateInteractiveTodosByCategory(todoItems!, host)) - .StartWith(Controls.Markdown("# πŸ“‚ Todos by Category\n\n*Loading todo items...*")); - } + public static UiControl TodosByCategory(LayoutAreaHost host, RenderingContext context) + => Page(host, host.Localize("todo.byCategory.title"), Typography.H2, withAdd: true, + Feed(host, TodoProjections.ByCategory)); /// - /// Creates a Summary layout area that shows interactive summary statistics with action buttons + /// Summary statistics. /// /// The layout area host /// The rendering context - /// An observable UI control showing interactive todo summary statistics + /// The area's template; its lines bind to the live todo list. [Display(GroupName = "2. Team Overview", Order = 1)] - public static IObservable Summary(LayoutAreaHost host, RenderingContext context) - { - _ = context; // Unused parameter but required by interface - return host.Workspace - .GetStream()! - .Select(todoItems => CreateInteractiveTodoSummary(todoItems!, host)) - .StartWith(Controls.Markdown("# πŸ“Š Todo Summary\n\n*Loading todo statistics...*")); - } + public static UiControl Summary(LayoutAreaHost host, RenderingContext context) + => Page(host, host.Localize("todo.summary.title"), Typography.H4, withAdd: false, + Feed(host, TodoProjections.Summary)); /// - /// Creates a Planning View for task assignment and workload management + /// Team workload and task assignment. /// /// The layout area host /// The rendering context - /// An observable UI control showing team workload and assignment interface + /// The area's template; its rows bind to the live todo list. [Display(GroupName = "3. Planning")] - public static IObservable Planning(LayoutAreaHost host, RenderingContext context) - { - _ = context; // Unused parameter but required by interface - return host.Workspace - .GetStream()! - .Select(todoItems => CreatePlanningView(todoItems!, host)) - .StartWith(Controls.Markdown("# 🎯 Planning & Assignment\n\n*Loading team workload...*")); - } + public static UiControl Planning(LayoutAreaHost host, RenderingContext context) + => Page(host, host.Localize("todo.planning.title"), Typography.H2, withAdd: true, + Feed(host, TodoProjections.Planning)); /// - /// Creates a view showing only the current user's active tasks + /// The current user's active tasks, by urgency. /// /// The layout area host /// The rendering context - /// An observable UI control showing current user's active tasks + /// The area's template; its rows bind to the live todo list. [Display(GroupName = "1. My Overview")] - public static IObservable MyTasks(LayoutAreaHost host, RenderingContext context) - { - _ = context; // Unused parameter but required by interface - return host.Workspace - .GetStream()! - .Select(todoItems => CreateMyActiveTasks(todoItems!, host)) - .StartWith(Controls.Markdown("# 🟒 My Active Tasks\n\n*Loading your tasks...*")); - } + public static UiControl MyTasks(LayoutAreaHost host, RenderingContext context) + => Page(host, host.Localize("todo.myTasks.title", ResponsiblePersons.GetCurrentUser()), Typography.H2, withAdd: true, + Feed(host, TodoProjections.MyTasks)); /// - /// Creates a view showing all unassigned tasks + /// Open tasks nobody is assigned to. /// /// The layout area host /// The rendering context - /// An observable UI control showing unassigned tasks + /// The area's template; its rows bind to the live todo list. [Display(GroupName = "3. Planning")] - public static IObservable Backlog(LayoutAreaHost host, RenderingContext context) - { - _ = context; // Unused parameter but required by interface - return host.Workspace - .GetStream()! - .Select(todoItems => CreateUnassignedTasks(todoItems!, host)) - .StartWith(Controls.Markdown("# πŸ“‹ Unassigned Tasks\n\n*Loading unassigned tasks...*")); - } + public static UiControl Backlog(LayoutAreaHost host, RenderingContext context) + => Page(host, host.Localize("todo.backlog.title"), Typography.H2, withAdd: true, + Feed(host, TodoProjections.Backlog)); /// - /// Creates a Today's Focus view showing all items due today + /// Overdue, due-today and ongoing work. /// /// The layout area host /// The rendering context - /// An observable UI control showing today's focus items + /// The area's template; its rows bind to the live todo list. [Display(GroupName = "2. Team Overview", Order = 0)] - public static IObservable TodaysFocus(LayoutAreaHost host, RenderingContext context) - { - _ = context; // Unused parameter but required by interface - return host.Workspace - .GetStream()! - .Select(todoItems => CreateTodaysFocus(todoItems!, host)) - .StartWith(Controls.Markdown("# 🎯 Today's Focus\n\n*Loading today's priorities...*")); - } - - /// - /// Creates clean summary statistics without dummy buttons - /// - /// The collection of todo items - /// The layout area host (unused parameter) - /// A clean LayoutGrid control with summary statistics only - private static UiControl? CreateInteractiveTodoSummary(IReadOnlyCollection todoItems, LayoutAreaHost _) - { - var mainGrid = Controls.LayoutGrid.WithSkin(skin => skin.WithSpacing(-1)); - - // Simple header - mainGrid = mainGrid - .WithView(Controls.H4("πŸ“Š Todo Dashboard") - .WithStyle(style => style.WithMarginBottom("6px").WithColor("var(--color-fg-default)")), - skin => skin.WithXs(12)); - - if (!todoItems.Any()) - { - mainGrid = mainGrid - .WithView(Controls.Markdown("*No todo items found.*") - .WithStyle(style => style.WithColor("var(--color-fg-muted)")), - skin => skin.WithXs(12)); - return mainGrid; - } - - // Overall statistics - var totalCount = todoItems.Count; - mainGrid = mainGrid - .WithView(Controls.Markdown($"**Total Items:** {totalCount}") - .WithStyle(style => style.WithMarginTop("20px").WithMarginBottom("8px")), - skin => skin.WithXs(12)); - - // Status breakdown - mainGrid = mainGrid - .WithView(Controls.H5("πŸ“ˆ Status Overview") - .WithStyle(style => style.WithMarginTop("12px").WithMarginBottom("6px").WithColor("var(--color-fg-default)")), - skin => skin.WithXs(12)); - - var statusGroups = todoItems.GroupBy(t => t.Status); - foreach (var group in statusGroups.OrderBy(g => (int)g.Key)) - { - var statusIcon = GetStatusIcon(group.Key); - var percentage = (group.Count() * 100.0 / totalCount).ToString("F1"); - - mainGrid = mainGrid - .WithView(Controls.Markdown($"{statusIcon} **{group.Key}**: {group.Count()} ({percentage}%)") - .WithStyle(style => style.WithPaddingLeft("20px").WithMarginBottom("5px")), - skin => skin.WithXs(12)); - } - - // Due date analysis - var now = DateTime.Now.Date; - var overdue = todoItems.Where(t => t.DueDate.HasValue && t.DueDate.Value.Date < now && t.Status != TodoStatus.Completed).ToList(); - var dueToday = todoItems.Where(t => t.DueDate.HasValue && t.DueDate.Value.Date == now && t.Status != TodoStatus.Completed).ToList(); - var dueSoon = todoItems.Where(t => t.DueDate.HasValue && t.DueDate.Value.Date > now && t.DueDate.Value.Date <= now.AddDays(7) && t.Status != TodoStatus.Completed).ToList(); - - mainGrid = mainGrid - .WithView(Controls.H5("⏰ Due Date Insights") - .WithStyle(style => style.WithMarginTop("16px").WithMarginBottom("6px").WithColor("var(--color-fg-default)")), - skin => skin.WithXs(12)); - - mainGrid = mainGrid - .WithView(Controls.Markdown($"🚨 **Overdue**: {overdue.Count}") - .WithStyle(style => style.WithPaddingLeft("20px").WithMarginBottom("5px")), - skin => skin.WithXs(12)); - - mainGrid = mainGrid - .WithView(Controls.Markdown($"⏰ **Due Today**: {dueToday.Count}") - .WithStyle(style => style.WithPaddingLeft("20px").WithMarginBottom("5px")), - skin => skin.WithXs(12)); - - mainGrid = mainGrid - .WithView(Controls.Markdown($"πŸ“… **Due This Week**: {dueSoon.Count}") - .WithStyle(style => style.WithPaddingLeft("20px").WithMarginBottom("5px")), - skin => skin.WithXs(12)); - - // Responsible person breakdown - var currentUser = ResponsiblePersons.GetCurrentUser(); - var myTodos = todoItems.Where(t => ResponsiblePersons.IsCurrentUser(t.ResponsiblePerson)).ToList(); - var otherTodos = todoItems.Where(t => !ResponsiblePersons.IsCurrentUser(t.ResponsiblePerson)).ToList(); - - var unassignedTodos = todoItems.Where(t => t.ResponsiblePerson == "Unassigned").ToList(); - - mainGrid = mainGrid - .WithView(Controls.H5("🟒 Responsibility Overview") - .WithStyle(style => style.WithMarginTop("16px").WithMarginBottom("6px").WithColor("var(--color-fg-default)")), - skin => skin.WithXs(12)); - - mainGrid = mainGrid - .WithView(Controls.Markdown($"🟒 **My Tasks ({currentUser})**: {myTodos.Count}") - .WithStyle(style => style.WithPaddingLeft("20px").WithMarginBottom("5px")), - skin => skin.WithXs(12)); - - mainGrid = mainGrid - .WithView(Controls.Markdown($"πŸ«‚ **Others' Tasks**: {otherTodos.Count}") - .WithStyle(style => style.WithPaddingLeft("20px").WithMarginBottom("5px")), - skin => skin.WithXs(12)); - - mainGrid = mainGrid - .WithView(Controls.Markdown($"πŸ“‹ **Unassigned Tasks**: {unassignedTodos.Count}") - .WithStyle(style => style.WithPaddingLeft("20px").WithMarginBottom("5px")), - skin => skin.WithXs(12)); - - return mainGrid; - } - - /// - /// Creates summary statistics for todo items in markdown format (legacy method) - /// - /// The collection of todo items - /// A markdown control with summary statistics - private static UiControl? CreateTodoSummaryMarkdown(IReadOnlyCollection todoItems) - { - var sb = new StringBuilder(); - sb.AppendLine("# πŸ“Š Todo Summary"); - sb.AppendLine(); - - if (!todoItems.Any()) - { - sb.AppendLine("*No todo items found.*"); - return Controls.Markdown(sb.ToString()); - } - - // Overall statistics - sb.AppendLine($"**Total Items:** {todoItems.Count}"); - sb.AppendLine(); - - // Status breakdown - sb.AppendLine("## Status Breakdown"); - var statusGroups = todoItems.GroupBy(t => t.Status); - foreach (var group in statusGroups.OrderBy(g => (int)g.Key)) - { - var statusIcon = GetStatusIcon(group.Key); - var percentage = (group.Count() * 100.0 / todoItems.Count).ToString("F1"); - sb.AppendLine($"- {statusIcon} **{group.Key}**: {group.Count()} ({percentage}%)"); - } - sb.AppendLine(); - - // Category breakdown - sb.AppendLine("## Category Breakdown"); - var categoryGroups = todoItems.GroupBy(t => string.IsNullOrEmpty(t.Category) ? "Uncategorized" : t.Category); - foreach (var group in categoryGroups.OrderByDescending(g => g.Count())) - { - sb.AppendLine($"- **{group.Key}**: {group.Count()}"); - } - sb.AppendLine(); - - // Due date analysis - var now = DateTime.Now.Date; - var overdue = todoItems.Count(t => t.DueDate.HasValue && t.DueDate.Value.Date < now && t.Status != TodoStatus.Completed); - var dueToday = todoItems.Count(t => t.DueDate.HasValue && t.DueDate.Value.Date == now && t.Status != TodoStatus.Completed); - var dueSoon = todoItems.Count(t => t.DueDate.HasValue && t.DueDate.Value.Date > now && t.DueDate.Value.Date <= now.AddDays(7) && t.Status != TodoStatus.Completed); - - sb.AppendLine("## Due Date Analysis"); - sb.AppendLine($"- 🚨 **Overdue**: {overdue}"); - sb.AppendLine($"- ⏰ **Due Today**: {dueToday}"); - sb.AppendLine($"- πŸ“… **Due This Week**: {dueSoon}"); - sb.AppendLine(); - - // Responsible person breakdown - sb.AppendLine("## Responsibility Breakdown"); - var currentUser = ResponsiblePersons.GetCurrentUser(); - var myTodos = todoItems.Where(t => ResponsiblePersons.IsCurrentUser(t.ResponsiblePerson)).ToList(); - var otherTodos = todoItems.Where(t => !ResponsiblePersons.IsCurrentUser(t.ResponsiblePerson)).ToList(); - - sb.AppendLine($"- 🟒 **My Tasks ({currentUser})**: {myTodos.Count}"); - sb.AppendLine($"- πŸ«‚ **Others' Tasks**: {otherTodos.Count}"); + public static UiControl TodaysFocus(LayoutAreaHost host, RenderingContext context) + => Page(host, host.Localize("todo.focus.title"), Typography.H4, withAdd: false, + Feed(host, TodoProjections.TodaysFocus)); - // Show top responsible persons - var personGroups = todoItems.GroupBy(t => t.ResponsiblePerson) - .OrderByDescending(g => g.Count()) - .Take(5); - - sb.AppendLine(); - sb.AppendLine("## Top Contributors"); - foreach (var group in personGroups) - { - var indicator = ResponsiblePersons.GetCurrentUserIndicator(group.Key); - sb.AppendLine($"- {indicator}**{group.Key}**: {group.Count()}"); - } - - return Controls.Markdown(sb.ToString()); - } - - /// - /// Creates interactive todos grouped by category with category-level and item-level actions - /// - /// The collection of todo items - /// The layout area host for submitting changes - /// An interactive LayoutGrid control with categorized todos and actions - private static UiControl? CreateInteractiveTodosByCategory(IReadOnlyCollection todoItems, LayoutAreaHost host) - { - var mainGrid = Controls.LayoutGrid.WithSkin(skin => skin.WithSpacing(-1)); - - // Header with title and global actions - mainGrid = mainGrid - .WithView(Controls.H2("πŸ“‚ Todo Categories") - .WithStyle(style => style.WithMarginBottom("6px").WithColor("var(--color-fg-default)")), - skin => skin.WithXs(12).WithSm(9).WithMd(10)) - .WithView(Controls.MenuItem("βž• Add New Todo", "plus") - .WithClickAction(_ => { SubmitNewTodo(host); return Task.CompletedTask; }) - .WithWidth(MenuWidth) - .WithAppearance(Appearance.Neutral) - .WithStyle(style => HeadingButtonStyle(style)), - skin => skin.WithXs(12).WithSm(3).WithMd(2)); - - if (!todoItems.Any()) - { - mainGrid = mainGrid - .WithView(Controls.Markdown("*No todo items found. Click 'Add New Todo' to get started!*") - .WithStyle(style => style.WithColor("var(--color-fg-muted)")), - skin => skin.WithXs(12).WithSm(9).WithMd(10)) - .WithView(Controls.Html(""), - skin => skin.WithXs(12).WithSm(3).WithMd(2)); - return mainGrid; - } - - var categoryGroups = todoItems - .GroupBy(t => string.IsNullOrEmpty(t.Category) ? "Uncategorized" : t.Category) - .OrderBy(g => g.Key); - - foreach (var categoryGroup in categoryGroups) - { - var categoryTodos = categoryGroup.ToList(); - var completedCount = categoryTodos.Count(t => t.Status == TodoStatus.Completed); - var pendingCount = categoryTodos.Count(t => t.Status == TodoStatus.Pending); - var inProgressCount = categoryTodos.Count(t => t.Status == TodoStatus.InProgress); - - // Category header with actions - var categoryActionButton = CreateCategoryActionButton(categoryTodos, host); - - mainGrid = mainGrid - .WithView(Controls.H5($"πŸ“ {categoryGroup.Key} ({categoryGroup.Count()}) - {completedCount}βœ… {inProgressCount}πŸ”„ {pendingCount}⏳") - .WithStyle(style => style.WithMarginTop("16px").WithMarginBottom("8px").WithColor("var(--color-fg-default)")), - skin => skin.WithXs(12).WithSm(9).WithMd(10)) - .WithView(categoryActionButton, - skin => skin.WithXs(12).WithSm(3).WithMd(2)); - - // Group todos by status within this category for better organization - var statusGroups = categoryTodos.GroupBy(t => t.Status).OrderBy(g => (int)g.Key); - - foreach (var statusGroup in statusGroups) - { - var sortedTodos = statusGroup - .OrderBy(t => t.DueDate ?? DateTime.MaxValue) - .ThenBy(t => t.CreatedAt); - - foreach (var todo in sortedTodos) - { - var (todoContent, todoActions) = CreateTodoItemContentAndActions(todo, host); - - mainGrid = mainGrid - .WithView(todoContent, - skin => skin.WithXs(12).WithSm(9).WithMd(10)) - .WithView(todoActions, - skin => skin.WithXs(12).WithSm(3).WithMd(2)); - } - } - } - - return mainGrid; - } - - /// - /// Creates todos grouped by category in markdown format (legacy method) - /// - /// The collection of todo items - /// A markdown control with todos grouped by category - private static UiControl? CreateTodosByCategoryMarkdown(IReadOnlyCollection todoItems) - { - var sb = new StringBuilder(); - sb.AppendLine("# πŸ“‚ Todos by Category"); - sb.AppendLine(); - - if (!todoItems.Any()) - { - sb.AppendLine("*No todo items found.*"); - return Controls.Markdown(sb.ToString()); - } - - var categoryGroups = todoItems - .GroupBy(t => string.IsNullOrEmpty(t.Category) ? "Uncategorized" : t.Category) - .OrderBy(g => g.Key); - - foreach (var categoryGroup in categoryGroups) - { - sb.AppendLine($"## {categoryGroup.Key} ({categoryGroup.Count()})"); - sb.AppendLine(); - - var sortedTodos = categoryGroup - .OrderBy(t => t.DueDate ?? DateTime.MaxValue) - .ThenBy(t => t.CreatedAt); - - foreach (var todo in sortedTodos) - { - sb.AppendLine(FormatTodoItem(todo)); - sb.AppendLine(); - } - } - - return Controls.Markdown(sb.ToString()); - } + // ── Template ─────────────────────────────────────────────────────────────────────────────── /// - /// Formats a single todo item for markdown display + /// The page every Todo area renders AT ONCE: its title, the "Add New Todo" action, and the row + /// list bound to . Reads nothing. /// - /// The todo item to format - /// Formatted markdown string for the todo item - private static string FormatTodoItem(TodoItem todo) + internal static LayoutGridControl Page(LayoutAreaHost host, string title, Typography typo, bool withAdd, + IObservable> entries) { - var sb = new StringBuilder(); - - var statusIcon = GetStatusIcon(todo.Status); - sb.AppendLine($"### {statusIcon} {todo.Title}"); - - if (!string.IsNullOrEmpty(todo.Description)) - { - sb.AppendLine($"*{todo.Description}*"); - } - - sb.AppendLine($"**Status:** {todo.Status}"); - sb.AppendLine($"**Category:** {todo.Category}"); - - var currentUserIndicator = ResponsiblePersons.GetCurrentUserIndicator(todo.ResponsiblePerson); - sb.AppendLine($"**Responsible:** {currentUserIndicator}{todo.ResponsiblePerson}"); - - if (todo.DueDate.HasValue) - { - var dueDateStr = todo.DueDate.Value.ToString("yyyy-MM-dd"); - var isOverdue = todo.DueDate.Value.Date < DateTime.Now.Date && todo.Status != TodoStatus.Completed; - var isDueToday = todo.DueDate.Value.Date == DateTime.Now.Date && todo.Status != TodoStatus.Completed; - - if (isOverdue) - sb.AppendLine($"**Due Date:** 🚨 {dueDateStr} *(Overdue)*"); - else if (isDueToday) - sb.AppendLine($"**Due Date:** ⏰ {dueDateStr} *(Due Today)*"); - else - sb.AppendLine($"**Due Date:** {dueDateStr}"); - } - - sb.AppendLine($"**Created:** {todo.CreatedAt:yyyy-MM-dd HH:mm}"); - - if (todo.UpdatedAt != todo.CreatedAt) - { - sb.AppendLine($"**Updated:** {todo.UpdatedAt:yyyy-MM-dd HH:mm}"); - } - - return sb.ToString(); - } - - /// - /// Gets the appropriate icon for a todo status - /// - /// The todo status - /// An emoji icon representing the status - private static string GetStatusIcon(TodoStatus status) => status switch - { - TodoStatus.Pending => "⏳", - TodoStatus.InProgress => "πŸ”„", - TodoStatus.Completed => "βœ…", - TodoStatus.Cancelled => "❌", - _ => "❓" - }; - - /// - /// Gets the appropriate color for a todo status - /// - /// The todo status - /// A CSS color variable for the status - private static string GetStatusColor(TodoStatus status) => status switch - { - TodoStatus.Pending => "var(--color-warning-fg)", - TodoStatus.InProgress => "var(--color-accent-fg)", - TodoStatus.Completed => "var(--color-success-fg)", - TodoStatus.Cancelled => "var(--color-danger-fg)", - _ => "var(--color-fg-default)" - }; - - /// - /// Creates an interactive todo list with a clean layout grid structure and vertically aligned action buttons - /// - /// The collection of todo items - /// The layout area host for submitting changes - /// A LayoutGrid control with structured todo items - private static UiControl? CreateInteractiveTodoListStack(IReadOnlyCollection todoItems, LayoutAreaHost host) - { - // Create main LayoutGrid with minimal spacing - var mainGrid = Controls.LayoutGrid.WithSkin(skin => skin.WithSpacing(-1)); - - // First row: Title and Add New Todo button - smaller heading with reduced spacing - mainGrid = mainGrid - .WithView(Controls.H4("πŸ“ Todo List with Actions") - .WithStyle(style => style.WithMarginBottom("6px").WithColor("var(--color-fg-default)")), - skin => skin.WithXs(12).WithSm(9).WithMd(10)) - .WithView(Controls.MenuItem("βž• Add New Todo", "plus") - .WithClickAction(_ => { SubmitNewTodo(host); return Task.CompletedTask; }) - .WithWidth(MenuWidth) - .WithAppearance(Appearance.Neutral) - .WithStyle(style => HeadingButtonStyle(style)), - skin => skin.WithXs(12).WithSm(3).WithMd(2)); - - if (!todoItems.Any()) - { - mainGrid = mainGrid - .WithView(Controls.Markdown("*No todo items found. Click 'Add New Todo' to get started!*") - .WithStyle(style => style.WithColor("var(--color-fg-muted)")), - skin => skin.WithXs(12).WithSm(9).WithMd(10)) - .WithView(Controls.Html(""), - skin => skin.WithXs(12).WithSm(3).WithMd(2)); - return mainGrid; - } - - // Order by due date (nulls last), then by created date - var orderedTodos = todoItems - .OrderBy(t => t.DueDate ?? DateTime.MaxValue) - .ThenBy(t => t.CreatedAt) - .ToList(); - - // Group by status for better organization, but separate unassigned tasks - var assignedTodos = orderedTodos.Where(t => t.ResponsiblePerson != "Unassigned").ToList(); - var unassignedTodos = orderedTodos.Where(t => t.ResponsiblePerson == "Unassigned").ToList(); - - var assignedStatusGroups = assignedTodos.GroupBy(t => t.Status).ToList(); - var unassignedStatusGroups = unassignedTodos.GroupBy(t => t.Status).ToList(); - - // First show assigned todos in collapsible status sections - foreach (var statusGroup in assignedStatusGroups.OrderBy(g => (int)g.Key)) - { - var statusIcon = GetStatusIcon(statusGroup.Key); - var statusName = statusGroup.Key.ToString(); - var statusColor = GetStatusColor(statusGroup.Key); - var defaultOpen = statusGroup.Key == TodoStatus.Pending || statusGroup.Key == TodoStatus.InProgress; - - // Create hierarchical action menu for status header - var statusActionButton = statusGroup.Key switch - { - TodoStatus.Pending => CreateStatusGroupMenuItem("▢️ Start All", "play", - (host, todos) => UpdateAllTodosInGroup(host, todos, TodoStatus.InProgress), - host, statusGroup), - TodoStatus.InProgress => CreateStatusGroupMenuItem("⏸️ Close All", "pause", - (host, todos) => UpdateAllTodosInGroup(host, todos, TodoStatus.Completed), - host, statusGroup), - TodoStatus.Completed => CreateStatusGroupMenuItem("πŸ“¦ Archive All", "archive", - DeleteAllTodosInGroup, - host, statusGroup), - TodoStatus.Cancelled => CreateStatusGroupMenuItem("πŸ—‘οΈ Delete All", "trash", - DeleteAllTodosInGroup, - host, statusGroup), - _ => Controls.Html("") // Empty placeholder for other statuses - }; - - mainGrid = AddCollapsibleStatusSection(mainGrid, host, statusGroup.ToList(), - $"{statusIcon} {statusName}", statusColor, statusActionButton, defaultOpen); - } - - // Then show unassigned todos after all assigned todos - if (unassignedTodos.Any()) - { - foreach (var statusGroup in unassignedStatusGroups.OrderBy(g => (int)g.Key)) - { - var statusIcon = GetStatusIcon(statusGroup.Key); - var statusName = $"Unassigned {statusGroup.Key}"; - - // Create action button for unassigned group - focus on assignment - var assignmentActionButton = CreateStatusGroupMenuItem("πŸ‘₯ Auto-Assign All", "user-plus", - (host, todos) => AutoAssignTasks(host, todos.ToList()), - host, statusGroup); - - mainGrid = AddCollapsibleUnassignedSection(mainGrid, host, statusGroup.ToList(), - $"{statusIcon} {statusName}", "var(--color-warning-fg)", assignmentActionButton, true); - } - } - - return mainGrid; - } - - - private const string MenuWidth = "150px"; - /// - /// Creates hierarchical menu item action controls with primary action and expandable secondary actions - /// - /// The todo item - /// The layout area host for submitting changes - /// A hierarchical menu item control with actions - private static UiControl CreateActionControls(TodoItem todo, LayoutAreaHost host) - { - var primaryAction = GetPrimaryActionData(todo); - var menuItem = Controls.MenuItem(primaryAction.Title, primaryAction.Icon) - .WithClickAction(_ => { primaryAction.Action(host, todo); return Task.CompletedTask; }) - .WithWidth(MenuWidth) - .WithAppearance(Appearance.Neutral) - .WithStyle(style => HeadingButtonStyle(style)); - - // Add secondary actions as sub-views - var secondaryActions = GetSecondaryActionData(todo); - foreach (var action in secondaryActions) - { - var subMenuItem = Controls.MenuItem(action.Title, action.Icon) - .WithClickAction(_ => { action.Action(host, todo); return Task.CompletedTask; }) - .WithWidth(MenuWidth) - .WithAppearance(Appearance.Neutral) - .WithStyle(style => HeadingButtonStyle(style)); - menuItem = menuItem.WithView(subMenuItem); - } - - // Add overdue reminder button if task is overdue - if (todo.DueDate.HasValue && todo.DueDate.Value.Date < DateTime.Now.Date && - todo.Status != TodoStatus.Completed && todo.Status != TodoStatus.Cancelled) - { - var reminderButton = CreateOverdueReminderButton(todo, host); - menuItem = menuItem.WithView(reminderButton); - } - - return menuItem; - } - - - - /// - /// Updates the status of a todo item - /// - /// The layout area host - /// The todo item to update - /// The new status - private static void UpdateTodoStatus(LayoutAreaHost host, TodoItem todo, TodoStatus newStatus) - { - var updatedTodo = todo with - { - Status = newStatus, - UpdatedAt = DateTime.UtcNow - }; - SubmitTodoUpdate(host, updatedTodo); - } - - /// - /// Submits an update request for a todo item - /// - /// The layout area host - /// The updated todo item - private static void SubmitTodoUpdate(LayoutAreaHost host, TodoItem updatedTodo) - { - var changeRequest = new DataChangeRequest() - .WithUpdates(updatedTodo); - - host.Hub.Post(changeRequest, o => o.WithTarget(TodoApplicationAttribute.Address)); - } - - /// - /// Submits a delete request for a todo item - /// - /// The layout area host - /// The todo item to delete - private static void SubmitTodoDelete(LayoutAreaHost host, TodoItem todoToDelete) - { - var changeRequest = new DataChangeRequest() - .WithDeletions(todoToDelete); - - host.Hub.Post(changeRequest, o => o.WithTarget(TodoApplicationAttribute.Address)); - } - - /// - /// Submits a request to create a new todo item using a dialog - /// - /// The layout area host - private static void SubmitNewTodo(LayoutAreaHost host) - { - // Create a new empty todo item for editing - var newTodo = new TodoItem - { - Id = Guid.NewGuid().AsString(), - Title = "", - Description = "", - Status = TodoStatus.Pending, - Category = "General", - ResponsiblePerson = ResponsiblePersons.GetCurrentUser(), // Default to current user - DueDate = DateTime.Now.AddDays(7), - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow - }; - - // Define the area ID for the new todo data - const string newTodoDataId = "NewTodoData"; - - // Create an edit form for the new todo item with proper data binding - var editForm = Controls.Stack - .WithView(Controls.H5("Create New Todo") - .WithStyle(style => style.WithWidth("100%").WithTextAlign("center"))) - .WithView(host.Edit(newTodo, newTodoDataId)? - .WithStyle(style => style.WithWidth("100%").WithDisplay("block")), newTodoDataId) - .WithView(Controls.Stack - .WithView(Controls.Button("πŸ’Ύ Save Todo") - .WithClickAction(_ => - { - // Changes are saved immediately ==> just - // Close the dialog by clearing the dialog area - host.UpdateArea(DialogControl.DialogArea, null!); - return Task.CompletedTask; - })) - .WithView(Controls.Button("❌ Cancel") - .WithClickAction(_ => - { - // since we have saved immediately, we need to now delete the entity. - host.Hub.Post(new DataChangeRequest() { Deletions = [newTodo] }, o => o.WithTarget(TodoApplicationAttribute.Address)); - - // Close the dialog by clearing the dialog area - host.UpdateArea(DialogControl.DialogArea, null!); - return Task.CompletedTask; - })) - .WithOrientation(Orientation.Horizontal) - .WithHorizontalGap(10) - .WithStyle(style => style.WithJustifyContent("center").WithWidth("100%"))) - .WithVerticalGap(15) - .WithStyle(style => style.WithWidth("100%").WithDisplay("block").WithMargin("0 auto")); - - // Create a dialog with the edit form content - DialogControl.Render() will handle the UiControl rendering - var dialog = Controls.Dialog(editForm, "Create New Todo") - .WithSize("M") - .WithClosable(false); // Disable the X button - only Save/Cancel buttons should close the dialog - - // Update the dialog area to show the dialog - host.UpdateArea(DialogControl.DialogArea, dialog); - } - - /// - /// Updates all todos in a status group to a new status - /// - /// The layout area host - /// The todos to update - /// The new status - private static void UpdateAllTodosInGroup(LayoutAreaHost host, IEnumerable todos, TodoStatus newStatus) - { - var updatedTodos = todos.Select(todo => todo with - { - Status = newStatus, - UpdatedAt = DateTime.UtcNow - }); - - var changeRequest = new DataChangeRequest() - .WithUpdates(updatedTodos); - - host.Hub.Post(changeRequest, o => o.WithTarget(TodoApplicationAttribute.Address)); - } - - /// - /// Deletes all todos in a group - /// - /// The layout area host - /// The todos to delete - private static void DeleteAllTodosInGroup(LayoutAreaHost host, IEnumerable todos) - { - var changeRequest = new DataChangeRequest() - .WithDeletions(todos.ToArray()); - - host.Hub.Post(changeRequest, o => o.WithTarget(TodoApplicationAttribute.Address)); - } - - /// - /// Creates compact content for a todo item (single line with key details) - /// - /// The todo item - /// Formatted content string - private static string CreateCompactTodoContent(TodoItem todo) - { - var sb = new StringBuilder(); - - // Title with emphasis - sb.Append($"**{todo.Title}**"); - - // Category badge if not default - if (todo.Category != "General") - { - sb.Append($" `{todo.Category}`"); - } - - // Responsible person with current user indicator - var displayName = ResponsiblePersons.GetDisplayName(todo.ResponsiblePerson); - sb.Append($" {displayName}"); - - // Due date with urgency indicators - if (todo.DueDate.HasValue) - { - var dueDateStr = todo.DueDate.Value.ToString("MMM dd"); - var isOverdue = todo.DueDate.Value.Date < DateTime.Now.Date && todo.Status != TodoStatus.Completed; - var isDueToday = todo.DueDate.Value.Date == DateTime.Now.Date && todo.Status != TodoStatus.Completed; - - if (isOverdue) - sb.Append($" 🚨 *{dueDateStr} (Overdue)*"); - else if (isDueToday) - sb.Append($" ⏰ *Due {dueDateStr}*"); - else - sb.Append($" πŸ“… *{dueDateStr}*"); - } - - // Description on new line if present - if (!string.IsNullOrEmpty(todo.Description)) - { - sb.AppendLine(); - sb.Append($"*{todo.Description}*"); - } - - return sb.ToString(); - } - - /// - /// Creates todo item content and actions as separate controls for layout grid - /// - /// The todo item - /// The layout area host for submitting changes - /// A tuple containing the content control and actions control - private static (UiControl content, UiControl actions) CreateTodoItemContentAndActions(TodoItem todo, LayoutAreaHost host) - { - var statusIcon = GetStatusIcon(todo.Status); - var contentMarkdown = CreateCompactTodoContent(todo); - var actionControls = CreateActionControls(todo, host); - - // Create content control with icon and text - more compact design - var content = Controls.Stack - .WithView(Controls.Markdown($"{statusIcon}")) - .WithView(Controls.Markdown(contentMarkdown)) - .WithOrientation(Orientation.Horizontal) - .WithHorizontalGap(12) - .WithStyle(style => style - .WithDisplay("flex") - .WithFlexDirection("row") - .WithAlignItems("flex-start") - .WithPadding("12px") - .WithMarginBottom("8px") - .WithBorder("1px solid var(--color-border-default)") - .WithBorderRadius("6px") - .WithBackgroundColor("var(--color-canvas-subtle)") - .WithBoxShadow("0 1px 2px var(--color-shadow-small)")); - - // Create actions control - centered and compact - var actions = Controls.Stack - .WithView(actionControls) - .WithStyle(style => style - .WithDisplay("flex") - .WithJustifyContent("center") - .WithAlignItems("center") - .WithPadding("12px") - .WithMarginBottom("8px")); - - return (content, actions); - } - - /// - /// Represents an action with its display information and handler - /// - /// The display title for the action - /// The icon for the action - /// The action handler - private record ActionData(string Title, string Icon, Action Action); - - /// - /// Gets the primary action data for a todo item - /// - /// The todo item - /// Primary action data - private static ActionData GetPrimaryActionData(TodoItem todo) - { - return todo.Status switch - { - TodoStatus.Pending => new ActionData("▢️ Start", "play", (host, item) => UpdateTodoStatus(host, item, TodoStatus.InProgress)), - TodoStatus.InProgress => new ActionData("βœ… Done", "check", (host, item) => UpdateTodoStatus(host, item, TodoStatus.Completed)), - TodoStatus.Completed => new ActionData("πŸ”„ Reopen", "refresh", (host, item) => UpdateTodoStatus(host, item, TodoStatus.InProgress)), - TodoStatus.Cancelled => new ActionData("πŸ”„ Restore", "refresh", (host, item) => UpdateTodoStatus(host, item, TodoStatus.Pending)), - _ => new ActionData("❓ Unknown", "help", (host, item) => { }) - }; - } - - /// - /// Gets the secondary action data for a todo item - /// - /// The todo item - /// List of secondary action data - private static List GetSecondaryActionData(TodoItem todo) - { - var actions = new List(); - - // Always add Edit action - actions.Add(new ActionData("✏️ Edit", "edit", (host, item) => SubmitEditTodo(host, item))); - - // Status-specific secondary actions - switch (todo.Status) - { - case TodoStatus.Pending: - actions.Add(new ActionData("βœ… Complete", "check", (host, item) => UpdateTodoStatus(host, item, TodoStatus.Completed))); - actions.Add(new ActionData("❌ Cancel", "close", (host, item) => UpdateTodoStatus(host, item, TodoStatus.Cancelled))); - break; - case TodoStatus.InProgress: - actions.Add(new ActionData("⏸️ Pause", "pause", (host, item) => UpdateTodoStatus(host, item, TodoStatus.Pending))); - actions.Add(new ActionData("❌ Cancel", "close", (host, item) => UpdateTodoStatus(host, item, TodoStatus.Cancelled))); - break; - case TodoStatus.Completed: - case TodoStatus.Cancelled: - // No additional status actions for completed/cancelled items - break; - } - - // Always add Delete action - actions.Add(new ActionData("πŸ—‘οΈ Delete", "trash", (host, item) => SubmitTodoDelete(host, item))); - - return actions; - } - - /// - /// Submits a request to edit an existing todo item using a dialog - /// - /// The layout area host - /// The todo item to edit - private static void SubmitEditTodo(LayoutAreaHost host, TodoItem todoToEdit) - { - // Define the area ID for the edit todo data - var editTodoDataId = $"EditTodoData_{todoToEdit.Id}"; - - // Create an edit form for the todo item with proper data binding - var editForm = Controls.Stack - .WithView(Controls.H5("Edit Todo") - .WithStyle(style => style.WithWidth("100%").WithTextAlign("center"))) - .WithView(host.Edit(todoToEdit, editTodoDataId)? - .WithStyle(style => style.WithWidth("100%").WithDisplay("block")), editTodoDataId) - .WithView(Controls.Stack - .WithView(Controls.Button("πŸ’Ύ Done") - .WithClickAction(_ => - { - // is updated on the fly, so we just need to close the dialog - // Close the dialog by clearing the dialog area - host.UpdateArea(DialogControl.DialogArea, null!); - return Task.CompletedTask; - })) - .WithOrientation(Orientation.Horizontal) - .WithHorizontalGap(10) - .WithStyle(style => style.WithJustifyContent("center").WithWidth("100%"))) - .WithVerticalGap(15) - .WithStyle(style => style.WithWidth("100%").WithDisplay("block").WithMargin("0 auto")); - - // Create a dialog with the edit form content - var dialog = Controls.Dialog(editForm, "Edit Todo") - .WithSize("M") - .WithClosable(false); // Disable the X button - only Save/Cancel buttons should close the dialog - - // Update the dialog area to show the dialog - host.UpdateArea(DialogControl.DialogArea, dialog); - } - - /// - /// Creates a hierarchical menu item for status group operations - /// - /// The primary action title - /// The primary action icon - /// The primary action to execute - /// The layout area host - /// The group of todos - /// A hierarchical menu item with group actions - private static UiControl CreateStatusGroupMenuItem(string title, string icon, - Action> primaryAction, - LayoutAreaHost host, IGrouping statusGroup) - { - // Create simple button with only primary action - no submenus for headings - var menuItem = Controls.MenuItem(title, icon) - .WithClickAction(_ => { primaryAction(host, statusGroup); return Task.CompletedTask; }) - .WithWidth(MenuWidth) - .WithAppearance(Appearance.Neutral) - .WithStyle(style => HeadingButtonStyle(style)); - - return menuItem; - } - - private static readonly Func HeadingButtonStyle = style => - style.WithDisplay("flex").WithAlignItems("center").WithJustifyContent("flex-end").WithHeight("100%") - .WithPaddingTop("20px"); - - /// - /// Creates the Planning View showing team workload and assignment interface - /// - /// The collection of todo items - /// The layout area host for submitting changes - /// An interactive LayoutGrid control for task planning - private static UiControl? CreatePlanningView(IReadOnlyCollection todoItems, LayoutAreaHost host) - { - var mainGrid = Controls.LayoutGrid.WithSkin(skin => skin.WithSpacing(-1)); - - // Header - mainGrid = mainGrid - .WithView(Controls.H2("🎯 Task Planning & Assignment") - .WithStyle(style => style.WithMarginBottom("6px").WithColor("var(--color-fg-default)")), - skin => skin.WithXs(12).WithSm(9).WithMd(10)) - .WithView(Controls.MenuItem("βž• Add New Todo", "plus") - .WithClickAction(_ => { SubmitNewTodo(host); return Task.CompletedTask; }) - .WithWidth(MenuWidth) - .WithAppearance(Appearance.Neutral) - .WithStyle(style => HeadingButtonStyle(style)), - skin => skin.WithXs(12).WithSm(3).WithMd(2)); - - if (!todoItems.Any()) - { - mainGrid = mainGrid - .WithView(Controls.Markdown("*No tasks found. Create some tasks to start planning!*") - .WithStyle(style => style.WithColor("var(--color-fg-muted)")), - skin => skin.WithXs(12)); - return mainGrid; - } - - // Team workload overview - var teamWorkload = todoItems - .Where(t => t.ResponsiblePerson != "Unassigned") - .GroupBy(t => t.ResponsiblePerson) - .OrderByDescending(g => g.Count()); - - var unassignedTasks = todoItems.Where(t => t.ResponsiblePerson == "Unassigned").ToList(); - - // Team Workload Section - mainGrid = mainGrid - .WithView(Controls.H5($"πŸ‘₯ Team Workload ({todoItems.Count(t => t.ResponsiblePerson != "Unassigned")} assigned)") - .WithStyle(style => style.WithMarginTop("20px").WithMarginBottom("8px").WithColor("var(--color-fg-default)")), - skin => skin.WithXs(12).WithSm(9).WithMd(10)) - .WithView(Controls.Html(""), - skin => skin.WithXs(12).WithSm(3).WithMd(2)); - - foreach (var personGroup in teamWorkload.Take(6)) // Show top 6 team members - { - var activeCount = personGroup.Count(t => t.Status != TodoStatus.Completed && t.Status != TodoStatus.Cancelled); - var overdueCount = personGroup.Count(t => t.DueDate.HasValue && t.DueDate.Value.Date < DateTime.Now.Date && t.Status != TodoStatus.Completed); - var isCurrentUser = ResponsiblePersons.IsCurrentUser(personGroup.Key); - var displayName = ResponsiblePersons.GetDisplayName(personGroup.Key); - - var workloadColor = activeCount switch - { - <= 2 => "🟒", // Light workload - <= 4 => "🟑", // Medium workload - _ => "πŸ”΄" // Heavy workload - }; - - var assignButton = unassignedTasks.Any() - ? Controls.MenuItem("πŸ“₯ Assign", "user-plus") - .WithClickAction(_ => - { - // Assign the first unassigned task to this person - var taskToAssign = unassignedTasks.First(); - var updatedTask = taskToAssign with - { - ResponsiblePerson = personGroup.Key, - UpdatedAt = DateTime.UtcNow - }; - SubmitTodoUpdate(host, updatedTask); - return Task.CompletedTask; - }) - .WithWidth(MenuWidth) + var grid = Controls.LayoutGrid.WithSkin(skin => skin.WithSpacing(-1)) + .WithView(Controls.Label(title).WithTypo(typo).WithStyle(style => style.WithMarginBottom("6px").WithColor("var(--color-fg-default)")), + skin => withAdd ? skin.WithXs(12).WithSm(9).WithMd(10) : skin.WithXs(12)); + if (withAdd) + grid = grid.WithView(Controls.MenuItem(host.Localize("todo.addNew"), "plus") + .WithClickAction(_ => { TodoRowActions.ShowNewTodoDialog(host); return Task.CompletedTask; }) + .WithWidth(TodoRowActions.MenuWidth) .WithAppearance(Appearance.Neutral) - .WithStyle(style => style.WithDisplay("flex").WithAlignItems("center").WithJustifyContent("flex-end")) - : (UiControl)Controls.Html(""); - - mainGrid = mainGrid - .WithView(Controls.Markdown($"{workloadColor} **{displayName}**: {activeCount} active" + - (overdueCount > 0 ? $" 🚨 {overdueCount} overdue" : "")) - .WithStyle(style => style.WithPaddingLeft("20px").WithMarginBottom("5px")), - skin => skin.WithXs(12).WithSm(9).WithMd(10)) - .WithView(assignButton, - skin => skin.WithXs(12).WithSm(3).WithMd(2)); - } - - // Unassigned Tasks Section - if (unassignedTasks.Any()) - { - mainGrid = mainGrid - .WithView(Controls.H5($"πŸ“‹ Unassigned Tasks ({unassignedTasks.Count})") - .WithStyle(style => style.WithMarginTop("16px").WithMarginBottom("8px").WithColor("var(--color-fg-default)")), - skin => skin.WithXs(12).WithSm(9).WithMd(10)) - .WithView(unassignedTasks.Any(t => t.Status != TodoStatus.Completed) - ? Controls.MenuItem("🎯 Auto-Assign", "shuffle") - .WithClickAction(_ => - { - AutoAssignTasks(host, unassignedTasks.Where(t => t.Status != TodoStatus.Completed)); - return Task.CompletedTask; - }) - .WithWidth(MenuWidth) - .WithAppearance(Appearance.Neutral) - .WithStyle(style => HeadingButtonStyle(style)) - : (UiControl)Controls.Html(""), - skin => skin.WithXs(12).WithSm(3).WithMd(2)); - - foreach (var task in unassignedTasks.Where(t => t.Status != TodoStatus.Completed).OrderBy(t => t.DueDate ?? DateTime.MaxValue).Take(8)) - { - var urgencyIndicator = task.DueDate.HasValue && task.DueDate.Value.Date <= DateTime.Now.Date ? "🚨" : - task.DueDate.HasValue && task.DueDate.Value.Date <= DateTime.Now.Date.AddDays(1) ? "⏰" : "πŸ“…"; - - var assignmentButton = CreateTaskAssignmentButton(task, host); - - mainGrid = mainGrid - .WithView(Controls.Markdown($"{urgencyIndicator} **{task.Title}** - `{task.Category}`" + - (task.DueDate.HasValue ? $" *Due: {task.DueDate.Value:MMM dd}*" : "")) - .WithStyle(style => style.WithPaddingLeft("20px").WithMarginBottom("5px")), - skin => skin.WithXs(12).WithSm(9).WithMd(10)) - .WithView(assignmentButton, - skin => skin.WithXs(12).WithSm(3).WithMd(2)); - } - } - - return mainGrid; - } - - /// - /// Creates the My Active Tasks view for personal productivity - /// - /// The collection of todo items - /// The layout area host for submitting changes - /// An interactive control showing current user's active tasks - private static UiControl? CreateMyActiveTasks(IReadOnlyCollection todoItems, LayoutAreaHost host) - { - var currentUser = ResponsiblePersons.GetCurrentUser(); - var myActiveTasks = todoItems - .Where(t => ResponsiblePersons.IsCurrentUser(t.ResponsiblePerson) && - (t.Status == TodoStatus.Pending || t.Status == TodoStatus.InProgress)) - .OrderBy(t => t.DueDate ?? DateTime.MaxValue) - .ThenBy(t => t.CreatedAt) - .ToList(); - - var mainGrid = Controls.LayoutGrid.WithSkin(skin => skin.WithSpacing(-1)); - - // Header - mainGrid = mainGrid - .WithView(Controls.H2($"🟒 My Active Tasks ({currentUser})") - .WithStyle(style => style.WithMarginBottom("6px").WithColor("var(--color-fg-default)")), - skin => skin.WithXs(12).WithSm(9).WithMd(10)) - .WithView(Controls.MenuItem("βž• Add New Todo", "plus") - .WithClickAction(_ => { SubmitNewTodo(host); return Task.CompletedTask; }) - .WithWidth(MenuWidth) - .WithAppearance(Appearance.Neutral) - .WithStyle(style => HeadingButtonStyle(style)), - skin => skin.WithXs(12).WithSm(3).WithMd(2)); - - if (!myActiveTasks.Any()) - { - mainGrid = mainGrid - .WithView(Controls.Markdown("πŸŽ‰ **All caught up!** You have no active tasks assigned.") - .WithStyle(style => style.WithColor("var(--color-success-fg)").WithTextAlign("center").WithMarginTop("40px")), - skin => skin.WithXs(12)); - return mainGrid; - } - - // Summary stats - var overdueCount = myActiveTasks.Count(t => t.DueDate.HasValue && t.DueDate.Value.Date < DateTime.Now.Date); - var dueTodayCount = myActiveTasks.Count(t => t.DueDate.HasValue && t.DueDate.Value.Date == DateTime.Now.Date); - var inProgressCount = myActiveTasks.Count(t => t.Status == TodoStatus.InProgress); - - mainGrid = mainGrid - .WithView(Controls.Markdown($"**{myActiveTasks.Count} active tasks** β€’ {inProgressCount} in progress" + - (overdueCount > 0 ? $" β€’ 🚨 {overdueCount} overdue" : "") + - (dueTodayCount > 0 ? $" β€’ ⏰ {dueTodayCount} due today" : "")) - .WithStyle(style => style.WithMarginBottom("12px")), - skin => skin.WithXs(12).WithSm(9).WithMd(10)) - .WithView(myActiveTasks.Any(t => t.Status == TodoStatus.Pending) - ? Controls.MenuItem("▢️ Start All", "play-circle") - .WithClickAction(_ => - { - var pendingTasks = myActiveTasks.Where(t => t.Status == TodoStatus.Pending); - UpdateAllTodosInGroup(host, pendingTasks, TodoStatus.InProgress); - return Task.CompletedTask; - }) - .WithWidth(MenuWidth) - .WithAppearance(Appearance.Neutral) - .WithStyle(style => HeadingButtonStyle(style)) - : (UiControl)Controls.Html(""), + .WithStyle(TodoStyles.Menu), skin => skin.WithXs(12).WithSm(3).WithMd(2)); - - // Tasks organized by urgency - var urgentTasks = myActiveTasks.Where(t => t.DueDate.HasValue && t.DueDate.Value.Date <= DateTime.Now.Date).ToList(); - var todayTasks = myActiveTasks.Where(t => t.DueDate.HasValue && t.DueDate.Value.Date == DateTime.Now.Date.AddDays(1)).ToList(); - var upcomingTasks = myActiveTasks.Where(t => !t.DueDate.HasValue || t.DueDate.Value.Date > DateTime.Now.Date.AddDays(1)).ToList(); - - if (urgentTasks.Any()) - { - mainGrid = mainGrid - .WithView(Controls.H5("🚨 Urgent (Overdue/Due Today)") - .WithStyle(style => style.WithMarginTop("20px").WithMarginBottom("6px").WithColor("var(--color-danger-fg)")), - skin => skin.WithXs(12)); - - foreach (var task in urgentTasks) - { - var (todoContent, todoActions) = CreateTodoItemContentAndActions(task, host); - mainGrid = mainGrid - .WithView(todoContent, skin => skin.WithXs(12).WithSm(9).WithMd(10)) - .WithView(todoActions, skin => skin.WithXs(12).WithSm(3).WithMd(2)); - } - } - - if (todayTasks.Any()) - { - mainGrid = mainGrid - .WithView(Controls.H5("⏰ Due Tomorrow") - .WithStyle(style => style.WithMarginTop("20px").WithMarginBottom("6px").WithColor("var(--color-warning-fg)")), - skin => skin.WithXs(12)); - - foreach (var task in todayTasks) - { - var (todoContent, todoActions) = CreateTodoItemContentAndActions(task, host); - mainGrid = mainGrid - .WithView(todoContent, skin => skin.WithXs(12).WithSm(9).WithMd(10)) - .WithView(todoActions, skin => skin.WithXs(12).WithSm(3).WithMd(2)); - } - } - - if (upcomingTasks.Any()) - { - mainGrid = mainGrid - .WithView(Controls.H5("πŸ“… Upcoming") - .WithStyle(style => style.WithMarginTop("20px").WithMarginBottom("6px").WithColor("var(--color-fg-default)")), - skin => skin.WithXs(12)); - - foreach (var task in upcomingTasks.Take(10)) // Limit to 10 upcoming tasks - { - var (todoContent, todoActions) = CreateTodoItemContentAndActions(task, host); - mainGrid = mainGrid - .WithView(todoContent, skin => skin.WithXs(12).WithSm(9).WithMd(10)) - .WithView(todoActions, skin => skin.WithXs(12).WithSm(3).WithMd(2)); - } - } - - return mainGrid; + var rowView = RowView(host); + return grid.WithView(entries.BindMany(EntriesId, _ => rowView), skin => skin.WithXs(12)); } /// - /// Creates the Unassigned Tasks view for task assignment + /// The ONE row template: a heading part (markdown + group action) and an item part (card + + /// action menu, or assignment menu). Every value is a relative pointer into the row; every + /// button is row-scoped. /// - /// The collection of todo items - /// The layout area host for submitting changes - /// An interactive control for managing unassigned tasks - private static UiControl? CreateUnassignedTasks(IReadOnlyCollection todoItems, LayoutAreaHost host) + internal static LayoutGridControl RowView(LayoutAreaHost host) { - var unassignedTasks = todoItems - .Where(t => t.ResponsiblePerson == "Unassigned" && t.Status != TodoStatus.Completed && t.Status != TodoStatus.Cancelled) - .OrderBy(t => t.DueDate ?? DateTime.MaxValue) - .ThenBy(t => t.CreatedAt) - .ToList(); - - var mainGrid = Controls.LayoutGrid.WithSkin(skin => skin.WithSpacing(-1)); + var actions = Styled(Controls.Stack.WithView(ItemMenu(host)), nameof(TodoEntry.ActionsStyle)); + var assign = Styled(Controls.Stack.WithView(AssignMenu(host)), nameof(TodoEntry.AssignStyle)); - // Header - mainGrid = mainGrid - .WithView(Controls.H2($"πŸ“‹ Unassigned Tasks ({unassignedTasks.Count})") - .WithStyle(style => style.WithMarginBottom("6px").WithColor("var(--color-fg-default)")), + return Controls.LayoutGrid.WithSkin(skin => skin.WithSpacing(-1)) + .WithView(Styled(Controls.Markdown(P(nameof(TodoEntry.Heading))), nameof(TodoEntry.HeadingStyle)), skin => skin.WithXs(12).WithSm(9).WithMd(10)) - .WithView(unassignedTasks.Any() - ? Controls.MenuItem("🎯 Auto-Assign All", "shuffle") - .WithClickAction(_ => - { - AutoAssignTasks(host, unassignedTasks); - return Task.CompletedTask; - }) - .WithWidth(MenuWidth) - .WithAppearance(Appearance.Neutral) - .WithStyle(style => HeadingButtonStyle(style)) - : Controls.MenuItem("βž• Add New Todo", "plus") - .WithClickAction(_ => { SubmitNewTodo(host); return Task.CompletedTask; }) - .WithWidth(MenuWidth) + .WithView(Controls.MenuItem(P(nameof(TodoEntry.GroupTitle)), P(nameof(TodoEntry.GroupIcon))) + .WithClickAction(ctx => TodoRowActions.OnGroup(ctx, host)) + .WithWidth(TodoRowActions.MenuWidth) .WithAppearance(Appearance.Neutral) - .WithStyle(style => HeadingButtonStyle(style)), - skin => skin.WithXs(12).WithSm(3).WithMd(2)); - - if (!unassignedTasks.Any()) - { - mainGrid = mainGrid - .WithView(Controls.Markdown("πŸŽ‰ **All tasks are assigned!** No unassigned tasks found.") - .WithStyle(style => style.WithColor("var(--color-success-fg)").WithTextAlign("center").WithMarginTop("40px")), - skin => skin.WithXs(12)); - return mainGrid; - } - - // Show unassigned tasks with assignment buttons - foreach (var task in unassignedTasks) - { - var urgencyIndicator = task.DueDate.HasValue && task.DueDate.Value.Date < DateTime.Now.Date ? "🚨" : - task.DueDate.HasValue && task.DueDate.Value.Date <= DateTime.Now.Date.AddDays(1) ? "⏰" : "πŸ“…"; - - var (todoContent, _) = CreateTodoItemContentAndActions(task, host); - var assignmentButton = CreateTaskAssignmentButton(task, host); - - mainGrid = mainGrid - .WithView(todoContent, - skin => skin.WithXs(12).WithSm(9).WithMd(10)) - .WithView(assignmentButton, - skin => skin.WithXs(12).WithSm(3).WithMd(2)); - } - - return mainGrid; - } - - /// - /// Creates Today's Focus view showing items due today - /// - /// The collection of todo items - /// The layout area host for submitting changes - /// An interactive control for today's priorities - private static UiControl? CreateTodaysFocus(IReadOnlyCollection todoItems, LayoutAreaHost host) - { - var today = DateTime.Now.Date; - - // Categorize tasks by timeline - var overdueTasks = todoItems - .Where(t => t.DueDate.HasValue && t.DueDate.Value.Date < today && - t.Status != TodoStatus.Completed && t.Status != TodoStatus.Cancelled) - .OrderBy(t => ResponsiblePersons.IsCurrentUser(t.ResponsiblePerson) ? 0 : 1) - .ThenBy(t => t.Status) - .ThenBy(t => t.CreatedAt) - .ToList(); - - var dueTodayTasks = todoItems - .Where(t => t.DueDate.HasValue && t.DueDate.Value.Date == today && - t.Status != TodoStatus.Completed && t.Status != TodoStatus.Cancelled) - .OrderBy(t => ResponsiblePersons.IsCurrentUser(t.ResponsiblePerson) ? 0 : 1) - .ThenBy(t => t.Status) - .ThenBy(t => t.CreatedAt) - .ToList(); - - var ongoingTasks = todoItems - .Where(t => t.ResponsiblePerson != "Unassigned" && - t.Status != TodoStatus.Completed && t.Status != TodoStatus.Cancelled && - (!t.DueDate.HasValue || t.DueDate.Value.Date > today)) // Only future or no due date - .OrderBy(t => ResponsiblePersons.IsCurrentUser(t.ResponsiblePerson) ? 0 : 1) - .ThenBy(t => t.Status) - .ThenBy(t => t.DueDate ?? DateTime.MaxValue) - .ThenBy(t => t.CreatedAt) - .ToList(); - - var totalUrgentTasks = overdueTasks.Count + dueTodayTasks.Count; - var mainGrid = Controls.LayoutGrid.WithSkin(skin => skin.WithSpacing(-1)); - - // Header with overall summary - var myUrgentTasks = overdueTasks.Concat(dueTodayTasks).Count(t => ResponsiblePersons.IsCurrentUser(t.ResponsiblePerson)); - var teamUrgentTasks = totalUrgentTasks - myUrgentTasks; - var myOngoingTasks = ongoingTasks.Count(t => ResponsiblePersons.IsCurrentUser(t.ResponsiblePerson)); - - var summaryText = $"**Overdue**: {overdueTasks.Count} β€’ **Due today**: {dueTodayTasks.Count} β€’ **Ongoing**: {ongoingTasks.Count}"; - - mainGrid = mainGrid - .WithView(Controls.H4("🎯 Today's Focus") - .WithStyle(style => style.WithMarginBottom("6px").WithColor("var(--color-fg-default)")), - skin => skin.WithXs(12)) - .WithView(Controls.Markdown(summaryText) - .WithStyle(style => style.WithMarginBottom("12px")), - skin => skin.WithXs(12)); - - if (totalUrgentTasks == 0 && ongoingTasks.Count == 0) - { - mainGrid = mainGrid - .WithView(Controls.Markdown("πŸŽ‰ **All caught up!** No urgent tasks or active work.") - .WithStyle(style => style.WithColor("var(--color-success-fg)").WithTextAlign("center").WithMarginTop("40px")), - skin => skin.WithXs(12)); - return mainGrid; - } - - // Section 1: 🚨 Overdue Tasks (highest priority) - if (overdueTasks.Any()) - { - mainGrid = AddTimelineSection(mainGrid, host, overdueTasks, - "🚨 Overdue", "var(--color-danger-fg)", - "▢️ Start All Overdue", "Start all overdue tasks"); - } - - // Section 2: ⏰ Due Today Tasks - if (dueTodayTasks.Any()) - { - mainGrid = AddTimelineSection(mainGrid, host, dueTodayTasks, - "⏰ Due Today", "var(--color-warning-fg)", - "▢️ Start All Today", "Start all due today tasks"); - } - - // Section 3: πŸ“‹ Ongoing Tasks (all assigned work not urgent) - if (ongoingTasks.Any()) - { - mainGrid = AddTimelineSection(mainGrid, host, ongoingTasks, - "πŸ“‹ Ongoing", "var(--color-accent-fg)", - "βœ… Complete All", "Complete all ongoing tasks"); - } - - return mainGrid; - } - - /// - /// Adds a timeline section to the Today's Focus view - /// - private static LayoutGridControl AddTimelineSection(LayoutGridControl mainGrid, LayoutAreaHost host, List tasks, - string sectionTitle, string sectionColor, string actionText, string actionDescription) - { - // Section header with action button - smaller heading with reduced spacing - mainGrid = mainGrid - .WithView(Controls.H5($"{sectionTitle} ({tasks.Count})") - .WithStyle(style => style.WithMarginTop("12px").WithMarginBottom("6px").WithColor(sectionColor)), - skin => skin.WithXs(12).WithSm(9).WithMd(10)) - .WithView(Controls.MenuItem(actionText, GetActionIcon(actionText)) - .WithClickAction(_ => - { - var targetStatus = actionText.Contains("Complete") ? TodoStatus.Completed : TodoStatus.InProgress; - UpdateAllTodosInGroup(host, tasks, targetStatus); - return Task.CompletedTask; - }) - .WithWidth(MenuWidth) - .WithAppearance(Appearance.Neutral) - .WithStyle(style => HeadingButtonStyle(style)), - skin => skin.WithXs(12).WithSm(3).WithMd(2)); - - // Show all tasks directly without person grouping headers - foreach (var task in tasks) - { - var (todoContent, todoActions) = CreateTodoItemContentAndActions(task, host); - mainGrid = mainGrid - .WithView(todoContent, skin => skin.WithXs(12).WithSm(9).WithMd(10)) - .WithView(todoActions, skin => skin.WithXs(12).WithSm(3).WithMd(2)); - } - - return mainGrid; - } - - - /// - /// Adds a collapsible status section for assigned todos in the AllItems - /// - private static LayoutGridControl AddCollapsibleStatusSection(LayoutGridControl mainGrid, LayoutAreaHost host, List tasks, - string sectionTitle, string sectionColor, UiControl actionButton, bool defaultOpen) - { - // Add collapsible section header with action button - var summaryHtml = $"" + - $"" + - $"{sectionTitle} ({tasks.Count})" + - "" + - "
"; - - mainGrid = mainGrid - .WithView(Controls.Html(summaryHtml), + .WithStyle(P(nameof(TodoEntry.GroupStyle))), + skin => skin.WithXs(12).WithSm(3).WithMd(2)) + .WithView(Styled(Controls.Stack + .WithView(Controls.Markdown(P(nameof(TodoEntry.Icon)))) + .WithView(Controls.Markdown(P(nameof(TodoEntry.Content)))) + .WithOrientation(Orientation.Horizontal) + .WithHorizontalGap(12), nameof(TodoEntry.ItemStyle)), skin => skin.WithXs(12).WithSm(9).WithMd(10)) - .WithView(actionButton, + .WithView(Controls.Stack.WithView(actions).WithView(assign), skin => skin.WithXs(12).WithSm(3).WithMd(2)); - - // Add each task with its action buttons inside the collapsible section - foreach (var task in tasks) - { - var (todoContent, todoActions) = CreateTodoItemContentAndActions(task, host); - mainGrid = mainGrid - .WithView(todoContent.WithStyle(style => style.WithMarginLeft("10px")), - skin => skin.WithXs(12).WithSm(9).WithMd(10)) - .WithView(todoActions, skin => skin.WithXs(12).WithSm(3).WithMd(2)); - } - - // Close the details section - mainGrid = mainGrid - .WithView(Controls.Html("
"), - skin => skin.WithXs(12)); - - return mainGrid; } - /// - /// Adds a collapsible status section for unassigned todos in the AllItems - /// - private static LayoutGridControl AddCollapsibleUnassignedSection(LayoutGridControl mainGrid, LayoutAreaHost host, List tasks, - string sectionTitle, string sectionColor, UiControl actionButton, bool defaultOpen) - { - // Add collapsible section header with action button - var summaryHtml = $"" + - $"" + - $"{sectionTitle} ({tasks.Count})" + - "" + - "
"; - - mainGrid = mainGrid - .WithView(Controls.Html(summaryHtml), - skin => skin.WithXs(12).WithSm(9).WithMd(10)) - .WithView(actionButton, - skin => skin.WithXs(12).WithSm(3).WithMd(2)); - - // Add each unassigned task with assignment button - foreach (var task in tasks) - { - var (todoContent, _) = CreateTodoItemContentAndActions(task, host); - var assignmentButton = CreateTaskAssignmentButton(task, host); - - mainGrid = mainGrid - .WithView(todoContent.WithStyle(style => style.WithMarginLeft("10px")), - skin => skin.WithXs(12).WithSm(9).WithMd(10)) - .WithView(Controls.Stack - .WithView(assignmentButton) - .WithStyle(style => style - .WithDisplay("flex") - .WithJustifyContent("center") - .WithAlignItems("center") - .WithPadding("12px") - .WithMarginBottom("8px")), - skin => skin.WithXs(12).WithSm(3).WithMd(2)); - } - - // Close the details section - mainGrid = mainGrid - .WithView(Controls.Html("
"), - skin => skin.WithXs(12)); - - return mainGrid; - } - - /// - /// Gets the appropriate icon for an action text - /// - private static string GetActionIcon(string actionText) - { - return actionText.Contains("Complete") ? "check" : "play"; - } - - /// - /// Creates a task assignment button with dropdown for team members - /// - /// The task to assign - /// The layout area host - /// A menu item with assignment options - private static UiControl CreateTaskAssignmentButton(TodoItem task, LayoutAreaHost host) - { - var assignButton = Controls.MenuItem("πŸ‘₯ Assign", "user-plus") - .WithWidth(MenuWidth) - .WithAppearance(Appearance.Neutral) - .WithStyle(style => HeadingButtonStyle(style)); - - // Add assignment options for each team member - foreach (var person in ResponsiblePersons.AvailablePersons.Take(5)) // Show top 5 for space - { - var displayName = ResponsiblePersons.GetDisplayName(person); - var assignToPerson = Controls.MenuItem(displayName, "user") - .WithClickAction(_ => - { - var updatedTask = task with - { - ResponsiblePerson = person, - UpdatedAt = DateTime.UtcNow - }; - SubmitTodoUpdate(host, updatedTask); - return Task.CompletedTask; - }) - .WithWidth(MenuWidth) + /// The item action menu: the status-dependent primary action, and the secondary + /// actions, each shown only where its bound style says so. + private static MenuItemControl ItemMenu(LayoutAreaHost host) + => Menu(Controls.MenuItem(P(nameof(TodoEntry.PrimaryTitle)), P(nameof(TodoEntry.PrimaryIcon))), + ctx => TodoRowActions.OnPrimary(ctx, host), TodoStyles.Menu) + .WithView(Menu(Controls.MenuItem(host.Localize("todo.action.edit"), "edit"), + ctx => TodoRowActions.OnEdit(ctx, host), TodoStyles.Menu)) + .WithView(Menu(Controls.MenuItem(host.Localize("todo.action.complete"), "check"), + ctx => TodoRowActions.OnSetStatus(ctx, host, TodoStatus.Completed), P(nameof(TodoEntry.CompleteStyle)))) + .WithView(Menu(Controls.MenuItem(host.Localize("todo.action.pause"), "pause"), + ctx => TodoRowActions.OnSetStatus(ctx, host, TodoStatus.Pending), P(nameof(TodoEntry.PauseStyle)))) + .WithView(Menu(Controls.MenuItem(host.Localize("todo.action.cancel"), "close"), + ctx => TodoRowActions.OnSetStatus(ctx, host, TodoStatus.Cancelled), P(nameof(TodoEntry.CancelStyle)))) + .WithView(Menu(Controls.MenuItem(host.Localize("todo.action.delete"), "trash"), + ctx => TodoRowActions.OnDelete(ctx, host), TodoStyles.Menu)) + .WithView(Menu(Controls.MenuItem(host.Localize("todo.action.sendReminder"), "send"), + ctx => TodoRowActions.OnReminder(ctx, host), P(nameof(TodoEntry.ReminderStyle)))); + + /// The assignment menu: one entry per team member, each assigning the row's todo. + private static MenuItemControl AssignMenu(LayoutAreaHost host) + => ResponsiblePersons.AvailablePersons.Take(5).Aggregate( + Controls.MenuItem(host.Localize("todo.action.assign"), "user-plus") + .WithWidth(TodoRowActions.MenuWidth) .WithAppearance(Appearance.Neutral) - .WithStyle(style => HeadingButtonStyle(style)); - - assignButton = assignButton.WithView(assignToPerson); - } - - return assignButton; - } - - /// - /// Auto-assigns tasks to team members using load balancing - /// - /// The layout area host - /// Tasks to assign - private static void AutoAssignTasks(LayoutAreaHost host, IEnumerable tasksToAssign) - { - var availablePersons = ResponsiblePersons.AvailablePersons.ToList(); - var personIndex = 0; - var updatedTasks = new List(); + .WithStyle(TodoStyles.Menu), + (menu, person) => menu.WithView(Menu( + Controls.MenuItem(TodoProjections.DisplayName(person, host.Localize), "user"), + ctx => TodoRowActions.OnAssign(ctx, host, person), TodoStyles.Menu))); + + private static MenuItemControl Menu(MenuItemControl item, Func onClick, object style) + => item.WithClickAction(onClick) + .WithWidth(TodoRowActions.MenuWidth) + .WithAppearance(Appearance.Neutral) + .WithStyle(style); - foreach (var task in tasksToAssign) - { - var assignedPerson = availablePersons[personIndex % availablePersons.Count]; - var updatedTask = task with - { - ResponsiblePerson = assignedPerson, - UpdatedAt = DateTime.UtcNow - }; - updatedTasks.Add(updatedTask); - personIndex++; - } + /// with its style BOUND to a row property: a part the row + /// does not have is hidden by it, so the template's shape never depends on data. + private static T Styled(T control, string styleProperty) where T : UiControl + => (T)(control with { Style = P(styleProperty) }); - if (updatedTasks.Any()) - { - var changeRequest = new DataChangeRequest() - .WithUpdates(updatedTasks); + /// A relative pointer to a property (camelCase, as the data + /// section stores it). + private static Data.JsonPointerReference P(string property) + => new(System.Text.Json.JsonNamingPolicy.CamelCase.ConvertName(property)); - host.Hub.Post(changeRequest, o => o.WithTarget(TodoApplicationAttribute.Address)); - } - } + // ── Feed ─────────────────────────────────────────────────────────────────────────────────── /// - /// Creates an action button for status groups in the summary view + /// The feed half: the live todos, projected into rows by in the + /// viewer's language. Builds no control. /// - /// The todo status - /// The group of todos with this status - /// The layout area host - /// An action button for the status group - private static UiControl CreateStatusSummaryButton(TodoStatus status, IGrouping statusGroup, LayoutAreaHost host) + internal static IObservable> Feed(LayoutAreaHost host, + Func, TodoViewText, IEnumerable> project) { - return status switch - { - TodoStatus.Pending => Controls.MenuItem("▢️ Start All", "play") - .WithClickAction(_ => { UpdateAllTodosInGroup(host, statusGroup, TodoStatus.InProgress); return Task.CompletedTask; }) - .WithWidth(MenuWidth) - .WithAppearance(Appearance.Neutral) - .WithStyle(style => style.WithDisplay("flex").WithAlignItems("center").WithJustifyContent("flex-end")), - - TodoStatus.InProgress => Controls.MenuItem("βœ… Complete All", "check-circle") - .WithClickAction(_ => { UpdateAllTodosInGroup(host, statusGroup, TodoStatus.Completed); return Task.CompletedTask; }) - .WithWidth(MenuWidth) - .WithAppearance(Appearance.Neutral) - .WithStyle(style => style.WithDisplay("flex").WithAlignItems("center").WithJustifyContent("flex-end")), - - TodoStatus.Completed => Controls.MenuItem("πŸ“¦ Archive All", "archive") - .WithClickAction(_ => { DeleteAllTodosInGroup(host, statusGroup); return Task.CompletedTask; }) - .WithWidth(MenuWidth) - .WithAppearance(Appearance.Neutral) - .WithStyle(style => style.WithDisplay("flex").WithAlignItems("center").WithJustifyContent("flex-end")), - - TodoStatus.Cancelled => Controls.MenuItem("πŸ”„ Restore All", "refresh-cw") - .WithClickAction(_ => { UpdateAllTodosInGroup(host, statusGroup, TodoStatus.Pending); return Task.CompletedTask; }) - .WithWidth(MenuWidth) - .WithAppearance(Appearance.Neutral) - .WithStyle(style => style.WithDisplay("flex").WithAlignItems("center").WithJustifyContent("flex-end")), - - _ => Controls.Html("") - }; + var text = TodoViewText.For(host); + var todos = host.Workspace.GetStream() + ?? throw new InvalidOperationException($"No data source for {nameof(TodoItem)} on {host.Hub.Address}."); + return todos.Select(items => project((IReadOnlyCollection?)items ?? [], text)); } +} - /// - /// Creates an action button for category groups - /// - /// The todos in this category - /// The layout area host - /// An action button for the category - private static UiControl CreateCategoryActionButton(List categoryTodos, LayoutAreaHost host) - { - var incompleteTodos = categoryTodos.Where(t => t.Status != TodoStatus.Completed).ToList(); - var pendingTodos = categoryTodos.Where(t => t.Status == TodoStatus.Pending).ToList(); - - // Determine the primary action based on category state - if (incompleteTodos.Any()) - { - if (pendingTodos.Count == incompleteTodos.Count) - { - // All incomplete todos are pending - offer to start all - return Controls.MenuItem("▢️ Start All", "play") - .WithClickAction(_ => { UpdateAllTodosInGroup(host, pendingTodos, TodoStatus.InProgress); return Task.CompletedTask; }) - .WithWidth(MenuWidth) - .WithAppearance(Appearance.Neutral) - .WithStyle(style => HeadingButtonStyle(style)); - } - else - { - // Mixed status - offer to complete all incomplete - return Controls.MenuItem("βœ… Complete All", "check-circle") - .WithClickAction(_ => { UpdateAllTodosInGroup(host, incompleteTodos, TodoStatus.Completed); return Task.CompletedTask; }) - .WithWidth(MenuWidth) - .WithAppearance(Appearance.Neutral) - .WithStyle(style => HeadingButtonStyle(style)); - } - } - else - { - // All todos are completed - offer to archive category - return Controls.MenuItem("πŸ“¦ Archive All", "archive") - .WithClickAction(_ => { DeleteAllTodosInGroup(host, categoryTodos); return Task.CompletedTask; }) - .WithWidth(MenuWidth) - .WithAppearance(Appearance.Neutral) - .WithStyle(style => HeadingButtonStyle(style)); - } - } - - /// - /// Shows overdue reminder dialog for a todo item - /// - /// The overdue todo item - /// The layout area host - private static void ShowOverdueReminderDialog(TodoItem todo, LayoutAreaHost host) - { - var defaultEmailText = $"Hi {todo.ResponsiblePerson},\n\n" + - $"This is a reminder that the following task is overdue:\n\n" + - $"Task: {todo.Title}\n" + - $"Description: {todo.Description}\n" + - $"Due Date: {todo.DueDate:yyyy-MM-dd}\n\n" + - $"Please complete this task as soon as possible.\n\n" + - $"Thanks!"; - - // Create the dialog content - var reminderForm = Controls.Stack - .WithView(Controls.H5("Send Overdue Reminder") - .WithStyle(style => style.WithWidth("100%").WithTextAlign("center"))) - .WithView(Controls.Markdown($"**Task:** {todo.Title}") - .WithStyle(style => style.WithMarginBottom("5px"))) - .WithView(Controls.Markdown($"**Assigned to:** {ResponsiblePersons.GetDisplayName(todo.ResponsiblePerson)}") - .WithStyle(style => style.WithMarginBottom("5px"))) - .WithView(Controls.Markdown($"**Due:** {todo.DueDate:yyyy-MM-dd} ({Math.Abs((DateTime.Now - todo.DueDate!.Value).Days)} days overdue)") - .WithStyle(style => style.WithMarginBottom("8px").WithColor("var(--color-danger-fg)"))) - .WithView(Controls.Markdown("**Email Text:**") - .WithStyle(style => style.WithMarginBottom("5px"))) - .WithView(Controls.Html($"") - .WithStyle(style => style.WithMarginBottom("8px"))) - .WithView(Controls.Stack - .WithView(Controls.Button("πŸ“§ Send Reminder") - .WithClickAction(_ => - { - // In a real implementation, this would send an email - System.Console.WriteLine($"OVERDUE REMINDER SENT:"); - System.Console.WriteLine($"To: {todo.ResponsiblePerson}"); - System.Console.WriteLine($"Subject: Overdue Task Reminder - {todo.Title}"); - System.Console.WriteLine($"Body:\n{defaultEmailText}"); - System.Console.WriteLine("=".PadRight(50, '=')); - - // Close the dialog - host.UpdateArea(DialogControl.DialogArea, null!); - return Task.CompletedTask; - })) - .WithView(Controls.Button("❌ Cancel") - .WithClickAction(_ => - { - // Close the dialog without action - host.UpdateArea(DialogControl.DialogArea, null!); - return Task.CompletedTask; - })) - .WithOrientation(Orientation.Horizontal) - .WithHorizontalGap(10) - .WithStyle(style => style.WithJustifyContent("center").WithWidth("100%"))) - .WithStyle(style => style.WithWidth("100%").WithDisplay("block").WithMargin("0 auto")); - - // Create the dialog - var dialog = Controls.Dialog(reminderForm, "Overdue Reminder") - .WithSize("M") - .WithClosable(false); - - // Show the dialog - host.UpdateArea(DialogControl.DialogArea, dialog); - } +/// +/// The viewer's language for the Todo projections: resolves a catalog key, and +/// formats dates β€” never the ambient culture (Doc/Architecture/Localization). +/// +/// Resolves a key with positional arguments. +/// The viewer's culture for date formatting. +public sealed record TodoViewText(Func Localize, CultureInfo Culture) +{ + /// The text for in the viewer's language. + public string T(string key, params object?[] args) => Localize(key, args); - /// - /// Creates a menu button for overdue reminder functionality - /// - /// The overdue todo item - /// The layout area host - /// Menu item for sending overdue reminders - private static UiControl CreateOverdueReminderButton(TodoItem todo, LayoutAreaHost host) - { - return Controls.MenuItem("πŸ“§ Send Reminder", "send") - .WithAppearance(Appearance.Neutral) - .WithClickAction(_ => - { - // Show the overdue reminder dialog - ShowOverdueReminderDialog(todo, host); - return Task.CompletedTask; - }) - .WithWidth(MenuWidth) - .WithStyle(style => HeadingButtonStyle(style)); - } + /// The viewer's text for . + public static TodoViewText For(LayoutAreaHost host) + => new(host.Localize, CultureInfo.GetCultureInfo(host.ViewerLocale())); } diff --git a/samples/Todo/MeshWeaver.Todo/LayoutAreas/TodoProjections.cs b/samples/Todo/MeshWeaver.Todo/LayoutAreas/TodoProjections.cs new file mode 100644 index 0000000000..d16d58f68d --- /dev/null +++ b/samples/Todo/MeshWeaver.Todo/LayoutAreas/TodoProjections.cs @@ -0,0 +1,412 @@ +using System.Collections.Immutable; +using MeshWeaver.Todo.Domain; + +namespace MeshWeaver.Todo.LayoutAreas; + +/// +/// The pure projections behind the Todo areas: todos in, the rows of the area's bound list out +/// (). They make every data-dependent decision β€” which sections exist, which +/// group action a section offers, which actions a todo offers β€” so the template stays static and +/// these are testable without a hub. +/// +public static class TodoProjections +{ + private const string Unassigned = "Unassigned"; + private const string DefaultColor = "var(--color-fg-default)"; + + // ── Areas ────────────────────────────────────────────────────────────────────────────────── + + /// All todos: assigned ones by status, then unassigned ones by status. + public static IEnumerable AllItems(IReadOnlyCollection todos, TodoViewText x) + { + if (todos.Count == 0) + { + yield return Line("empty", x.T("todo.emptyAddHint"), TodoStyles.Muted); + yield break; + } + + var ordered = todos.OrderBy(t => t.DueDate ?? DateTime.MaxValue).ThenBy(t => t.CreatedAt).ToList(); + + foreach (var group in ordered.Where(t => t.ResponsiblePerson != Unassigned) + .GroupBy(t => t.Status).OrderBy(g => (int)g.Key)) + { + var (title, icon, action) = group.Key switch + { + TodoStatus.Pending => (x.T("todo.group.startAll"), "play", TodoGroupActions.Start), + TodoStatus.InProgress => (x.T("todo.group.closeAll"), "pause", TodoGroupActions.Complete), + TodoStatus.Completed => (x.T("todo.group.archiveAll"), "archive", TodoGroupActions.Delete), + _ => (x.T("todo.group.deleteAll"), "trash", TodoGroupActions.Delete), + }; + yield return Section($"assigned-{group.Key}", + x.T("todo.sectionCount", $"{StatusIcon(group.Key)} {StatusName(group.Key, x)}", group.Count()), + StatusColor(group.Key), "20px", title, icon, action, group); + foreach (var todo in group) + yield return ItemRow(todo, x, TodoStyles.IndentedCard); + } + + foreach (var group in ordered.Where(t => t.ResponsiblePerson == Unassigned) + .GroupBy(t => t.Status).OrderBy(g => (int)g.Key)) + { + yield return Section($"unassigned-{group.Key}", + x.T("todo.sectionCount", + $"{StatusIcon(group.Key)} {x.T("todo.unassignedStatus", StatusName(group.Key, x))}", group.Count()), + "var(--color-warning-fg)", "20px", + x.T("todo.group.autoAssignAll"), "user-plus", TodoGroupActions.AutoAssign, group); + foreach (var todo in group) + yield return AssignRow(todo, x, TodoStyles.IndentedCard, Content(todo, x)); + } + } + + /// Todos grouped by category, each category with its bulk action. + public static IEnumerable ByCategory(IReadOnlyCollection todos, TodoViewText x) + { + if (todos.Count == 0) + { + yield return Line("empty", x.T("todo.emptyAddHint"), TodoStyles.Muted); + yield break; + } + + foreach (var category in todos + .GroupBy(t => string.IsNullOrEmpty(t.Category) ? x.T("todo.uncategorized") : t.Category) + .OrderBy(g => g.Key, StringComparer.Ordinal)) + { + var all = category.ToList(); + var incomplete = all.Where(t => t.Status != TodoStatus.Completed).ToList(); + var pending = all.Where(t => t.Status == TodoStatus.Pending).ToList(); + var (title, icon, action, group) = + incomplete.Count == 0 ? (x.T("todo.group.archiveAll"), "archive", TodoGroupActions.Delete, all) + : pending.Count == incomplete.Count ? (x.T("todo.group.startAll"), "play", TodoGroupActions.Start, pending) + : (x.T("todo.group.completeAll"), "check-circle", TodoGroupActions.Complete, incomplete); + + yield return Section($"category-{category.Key}", + x.T("todo.byCategory.header", category.Key, all.Count, + all.Count(t => t.Status == TodoStatus.Completed), + all.Count(t => t.Status == TodoStatus.InProgress), + pending.Count), + DefaultColor, "16px", title, icon, action, group); + + foreach (var todo in all.OrderBy(t => (int)t.Status) + .ThenBy(t => t.DueDate ?? DateTime.MaxValue).ThenBy(t => t.CreatedAt)) + yield return ItemRow(todo, x, TodoStyles.Card); + } + } + + /// Summary statistics: status breakdown, due dates, responsibility. + public static IEnumerable Summary(IReadOnlyCollection todos, TodoViewText x) + { + if (todos.Count == 0) + { + yield return Line("empty", x.T("todo.none"), TodoStyles.Muted); + yield break; + } + + var total = todos.Count; + yield return Line("total", x.T("todo.summary.total", total), "margin-top: 20px; margin-bottom: 8px;"); + + yield return Section("status", x.T("todo.summary.statusOverview"), DefaultColor, "12px"); + foreach (var group in todos.GroupBy(t => t.Status).OrderBy(g => (int)g.Key)) + yield return Line($"status-{group.Key}", x.T("todo.summary.statusLine", StatusIcon(group.Key), + StatusName(group.Key, x), group.Count(), (group.Count() * 100.0 / total).ToString("F1", x.Culture)), + TodoStyles.Stat); + + var today = DateTime.Now.Date; + bool Open(TodoItem t) => t.Status != TodoStatus.Completed; + yield return Section("due", x.T("todo.summary.dueInsights"), DefaultColor); + yield return Line("overdue", x.T("todo.summary.overdue", + todos.Count(t => t.DueDate?.Date < today && Open(t))), TodoStyles.Stat); + yield return Line("dueToday", x.T("todo.summary.dueToday", + todos.Count(t => t.DueDate?.Date == today && Open(t))), TodoStyles.Stat); + yield return Line("dueWeek", x.T("todo.summary.dueWeek", + todos.Count(t => t.DueDate?.Date > today && t.DueDate?.Date <= today.AddDays(7) && Open(t))), TodoStyles.Stat); + + yield return Section("responsibility", x.T("todo.summary.responsibility"), DefaultColor); + yield return Line("mine", x.T("todo.summary.mine", ResponsiblePersons.GetCurrentUser(), + todos.Count(t => ResponsiblePersons.IsCurrentUser(t.ResponsiblePerson))), TodoStyles.Stat); + yield return Line("others", x.T("todo.summary.others", + todos.Count(t => !ResponsiblePersons.IsCurrentUser(t.ResponsiblePerson))), TodoStyles.Stat); + yield return Line("unassigned", x.T("todo.summary.unassigned", + todos.Count(t => t.ResponsiblePerson == Unassigned)), TodoStyles.Stat); + } + + /// Team workload (with "assign the next unassigned task" per member) and the + /// unassigned backlog with per-task assignment. + public static IEnumerable Planning(IReadOnlyCollection todos, TodoViewText x) + { + if (todos.Count == 0) + { + yield return Line("empty", x.T("todo.planning.empty"), TodoStyles.Muted); + yield break; + } + + var today = DateTime.Now.Date; + var unassigned = todos.Where(t => t.ResponsiblePerson == Unassigned).ToList(); + + yield return Section("workload", + x.T("todo.planning.workload", todos.Count(t => t.ResponsiblePerson != Unassigned)), DefaultColor, "20px"); + + foreach (var person in todos.Where(t => t.ResponsiblePerson != Unassigned) + .GroupBy(t => t.ResponsiblePerson).OrderByDescending(g => g.Count()).Take(6)) + { + var active = person.Count(t => t.Status != TodoStatus.Completed && t.Status != TodoStatus.Cancelled); + var overdue = person.Count(t => t.DueDate?.Date < today && t.Status != TodoStatus.Completed); + var load = active switch { <= 2 => "🟒", <= 4 => "🟑", _ => "πŸ”΄" }; + var line = x.T("todo.planning.member", load, DisplayName(person.Key, x.Localize), active) + + (overdue > 0 ? x.T("todo.planning.memberOverdue", overdue) : ""); + var entry = Line($"member-{person.Key}", line, TodoStyles.Stat); + yield return unassigned.Count == 0 + ? entry + : entry with + { + GroupTitle = x.T("todo.group.assignOne"), + GroupIcon = "user-plus", + GroupAction = TodoGroupActions.AssignFirst, + GroupStyle = TodoStyles.Menu, + Group = [.. unassigned], + Person = person.Key, + }; + } + + if (unassigned.Count == 0) + yield break; + + var open = unassigned.Where(t => t.Status != TodoStatus.Completed).ToList(); + yield return open.Count == 0 + ? Section("backlog", x.T("todo.planning.unassigned", unassigned.Count), DefaultColor) + : Section("backlog", x.T("todo.planning.unassigned", unassigned.Count), DefaultColor, "16px", + x.T("todo.group.autoAssign"), "shuffle", TodoGroupActions.AutoAssign, open); + + foreach (var task in open.OrderBy(t => t.DueDate ?? DateTime.MaxValue).Take(8)) + { + var urgency = task.DueDate?.Date <= today ? "🚨" : task.DueDate?.Date <= today.AddDays(1) ? "⏰" : "πŸ“…"; + var line = x.T("todo.planning.task", urgency, task.Title, task.Category) + + (task.DueDate is { } due ? x.T("todo.planning.taskDue", due.ToString("MMM dd", x.Culture)) : ""); + yield return AssignRow(task, x, TodoStyles.Line, line) with { Icon = string.Empty }; + } + } + + /// The current user's open tasks: urgent, due tomorrow, upcoming. + public static IEnumerable MyTasks(IReadOnlyCollection todos, TodoViewText x) + { + var today = DateTime.Now.Date; + var mine = todos + .Where(t => ResponsiblePersons.IsCurrentUser(t.ResponsiblePerson) + && t.Status is TodoStatus.Pending or TodoStatus.InProgress) + .OrderBy(t => t.DueDate ?? DateTime.MaxValue).ThenBy(t => t.CreatedAt) + .ToList(); + + if (mine.Count == 0) + { + yield return Line("empty", x.T("todo.myTasks.empty"), TodoStyles.Success); + yield break; + } + + var overdue = mine.Count(t => t.DueDate?.Date < today); + var dueToday = mine.Count(t => t.DueDate?.Date == today); + var summary = x.T("todo.myTasks.summary", mine.Count, mine.Count(t => t.Status == TodoStatus.InProgress)) + + (overdue > 0 ? x.T("todo.myTasks.overdue", overdue) : "") + + (dueToday > 0 ? x.T("todo.myTasks.dueToday", dueToday) : ""); + var pending = mine.Where(t => t.Status == TodoStatus.Pending).ToList(); + var line = Line("summary", summary, "margin-bottom: 12px;"); + yield return pending.Count == 0 + ? line + : line with + { + GroupTitle = x.T("todo.group.startAll"), + GroupIcon = "play-circle", + GroupAction = TodoGroupActions.Start, + GroupStyle = TodoStyles.Menu, + Group = [.. pending], + }; + + var sections = new[] + { + ("urgent", x.T("todo.myTasks.urgent"), "var(--color-danger-fg)", + mine.Where(t => t.DueDate?.Date <= today).ToList()), + ("tomorrow", x.T("todo.myTasks.tomorrow"), "var(--color-warning-fg)", + mine.Where(t => t.DueDate?.Date == today.AddDays(1)).ToList()), + ("upcoming", x.T("todo.myTasks.upcoming"), DefaultColor, + mine.Where(t => t.DueDate is null || t.DueDate.Value.Date > today.AddDays(1)).Take(10).ToList()), + }; + foreach (var (key, title, color, tasks) in sections.Where(s => s.Item4.Count > 0)) + { + yield return Section(key, title, color, "20px"); + foreach (var todo in tasks) + yield return ItemRow(todo, x, TodoStyles.Card); + } + } + + /// Open tasks nobody is assigned to, each with its assignment menu. + public static IEnumerable Backlog(IReadOnlyCollection todos, TodoViewText x) + { + var open = todos + .Where(t => t.ResponsiblePerson == Unassigned + && t.Status != TodoStatus.Completed && t.Status != TodoStatus.Cancelled) + .OrderBy(t => t.DueDate ?? DateTime.MaxValue).ThenBy(t => t.CreatedAt) + .ToList(); + + if (open.Count == 0) + { + yield return Line("empty", x.T("todo.backlog.empty"), TodoStyles.Success); + yield break; + } + + yield return Section("backlog", x.T("todo.planning.unassigned", open.Count), DefaultColor, "6px", + x.T("todo.group.autoAssignAll"), "shuffle", TodoGroupActions.AutoAssign, open); + foreach (var todo in open) + yield return AssignRow(todo, x, TodoStyles.Card, Content(todo, x)); + } + + /// Overdue, due-today and ongoing work, each section with its bulk action. + public static IEnumerable TodaysFocus(IReadOnlyCollection todos, TodoViewText x) + { + var today = DateTime.Now.Date; + bool Open(TodoItem t) => t.Status != TodoStatus.Completed && t.Status != TodoStatus.Cancelled; + IOrderedEnumerable MineFirst(IEnumerable items) + => items.OrderBy(t => ResponsiblePersons.IsCurrentUser(t.ResponsiblePerson) ? 0 : 1).ThenBy(t => t.Status); + + var overdue = MineFirst(todos.Where(t => t.DueDate?.Date < today && Open(t))).ThenBy(t => t.CreatedAt).ToList(); + var dueToday = MineFirst(todos.Where(t => t.DueDate?.Date == today && Open(t))).ThenBy(t => t.CreatedAt).ToList(); + var ongoing = MineFirst(todos.Where(t => t.ResponsiblePerson != Unassigned && Open(t) + && (t.DueDate is null || t.DueDate.Value.Date > today))) + .ThenBy(t => t.DueDate ?? DateTime.MaxValue).ThenBy(t => t.CreatedAt).ToList(); + + yield return Line("summary", x.T("todo.focus.summary", overdue.Count, dueToday.Count, ongoing.Count), + "margin-bottom: 12px;"); + + if (overdue.Count + dueToday.Count + ongoing.Count == 0) + { + yield return Line("empty", x.T("todo.focus.empty"), TodoStyles.Success); + yield break; + } + + var sections = new[] + { + ("overdue", x.T("todo.focus.overdue"), "var(--color-danger-fg)", x.T("todo.group.startAllOverdue"), + "play", TodoGroupActions.Start, overdue), + ("dueToday", x.T("todo.focus.dueToday"), "var(--color-warning-fg)", x.T("todo.group.startAllToday"), + "play", TodoGroupActions.Start, dueToday), + ("ongoing", x.T("todo.focus.ongoing"), "var(--color-accent-fg)", x.T("todo.group.completeAll"), + "check", TodoGroupActions.Complete, ongoing), + }; + foreach (var (key, title, color, action, icon, code, tasks) in sections.Where(s => s.Item7.Count > 0)) + { + yield return Section(key, x.T("todo.sectionCount", title, tasks.Count), color, "12px", + action, icon, code, tasks); + foreach (var todo in tasks) + yield return ItemRow(todo, x, TodoStyles.Card); + } + } + + // ── Rows ─────────────────────────────────────────────────────────────────────────────────── + + /// A section heading, optionally with a group action over . + public static TodoEntry Section(string key, string title, string color, string marginTop = "16px", + string? groupTitle = null, string groupIcon = "", string groupAction = "", IEnumerable? group = null) + => new() + { + Key = key, + Heading = $"##### {title}", + HeadingStyle = TodoStyles.Heading(color, marginTop), + GroupTitle = groupTitle ?? string.Empty, + GroupIcon = groupIcon, + GroupAction = groupAction, + GroupStyle = groupTitle is null ? TodoStyles.Hidden : TodoStyles.Menu, + Group = group is null ? [] : [.. group], + }; + + /// A markdown line (a statistic, a summary, an empty state). + public static TodoEntry Line(string key, string markdown, string style) + => new() { Key = key, Heading = markdown, HeadingStyle = style }; + + /// A todo card with its action menu: the primary action for its status, and the + /// secondary actions its status offers. + public static TodoEntry ItemRow(TodoItem todo, TodoViewText x, string cardStyle) + { + var (title, icon) = todo.Status switch + { + TodoStatus.Pending => (x.T("todo.action.start"), "play"), + TodoStatus.InProgress => (x.T("todo.action.done"), "check"), + TodoStatus.Completed => (x.T("todo.action.reopen"), "refresh"), + _ => (x.T("todo.action.restore"), "refresh"), + }; + var overdue = todo.DueDate?.Date < DateTime.Now.Date + && todo.Status is not (TodoStatus.Completed or TodoStatus.Cancelled); + return Card(todo, x, cardStyle, Content(todo, x)) with + { + ActionsStyle = TodoStyles.Actions, + PrimaryTitle = title, + PrimaryIcon = icon, + CompleteStyle = todo.Status == TodoStatus.Pending ? TodoStyles.Menu : TodoStyles.Hidden, + PauseStyle = todo.Status == TodoStatus.InProgress ? TodoStyles.Menu : TodoStyles.Hidden, + CancelStyle = todo.Status is TodoStatus.Pending or TodoStatus.InProgress ? TodoStyles.Menu : TodoStyles.Hidden, + ReminderStyle = overdue ? TodoStyles.Menu : TodoStyles.Hidden, + }; + } + + /// A todo with the assignment menu instead of its actions. + public static TodoEntry AssignRow(TodoItem todo, TodoViewText x, string cardStyle, string content) + => Card(todo, x, cardStyle, content) with { AssignStyle = TodoStyles.Actions }; + + private static TodoEntry Card(TodoItem todo, TodoViewText x, string cardStyle, string content) + => new() + { + Key = $"todo-{todo.Id}", + Item = todo, + ItemStyle = cardStyle, + Icon = StatusIcon(todo.Status), + Content = content, + }; + + /// The compact card text: title, category, responsible person, due date, description. + public static string Content(TodoItem todo, TodoViewText x) + { + var text = $"**{todo.Title}**"; + if (todo.Category != "General") + text += $" `{todo.Category}`"; + text += $" {DisplayName(todo.ResponsiblePerson, x.Localize)}"; + if (todo.DueDate is { } due) + { + var date = due.ToString("MMM dd", x.Culture); + var open = todo.Status != TodoStatus.Completed; + text += " " + (due.Date < DateTime.Now.Date && open ? x.T("todo.due.overdue", date) + : due.Date == DateTime.Now.Date && open ? x.T("todo.due.today", date) + : x.T("todo.due.on", date)); + } + if (!string.IsNullOrEmpty(todo.Description)) + text += $"\n*{todo.Description}*"; + return text; + } + + /// A person's name, marked when it is the current user. + public static string DisplayName(string person, Func localize) + => ResponsiblePersons.IsCurrentUser(person) ? localize("todo.you", [person]) : person; + + /// The status glyph. + public static string StatusIcon(TodoStatus status) => status switch + { + TodoStatus.Pending => "⏳", + TodoStatus.InProgress => "πŸ”„", + TodoStatus.Completed => "βœ…", + TodoStatus.Cancelled => "❌", + _ => "❓" + }; + + /// The status name in the viewer's language. + public static string StatusName(TodoStatus status, TodoViewText x) => status switch + { + TodoStatus.Pending => x.T("todo.status.pending"), + TodoStatus.InProgress => x.T("todo.status.inProgress"), + TodoStatus.Completed => x.T("todo.status.completed"), + TodoStatus.Cancelled => x.T("todo.status.cancelled"), + _ => status.ToString() + }; + + private static string StatusColor(TodoStatus status) => status switch + { + TodoStatus.Pending => "var(--color-warning-fg)", + TodoStatus.InProgress => "var(--color-accent-fg)", + TodoStatus.Completed => "var(--color-success-fg)", + TodoStatus.Cancelled => "var(--color-danger-fg)", + _ => DefaultColor + }; +} diff --git a/samples/Todo/MeshWeaver.Todo/LayoutAreas/TodoRowActions.cs b/samples/Todo/MeshWeaver.Todo/LayoutAreas/TodoRowActions.cs new file mode 100644 index 0000000000..db455bbad3 --- /dev/null +++ b/samples/Todo/MeshWeaver.Todo/LayoutAreas/TodoRowActions.cs @@ -0,0 +1,243 @@ +using MeshWeaver.Data; +using MeshWeaver.Layout; +using MeshWeaver.Layout.Composition; +using MeshWeaver.Mesh; +using MeshWeaver.ShortGuid; +using MeshWeaver.Todo.Domain; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace MeshWeaver.Todo.LayoutAreas; + +/// +/// The actions of the Todo row template β€” ROW-SCOPED (Doc/GUI/DataBinding β†’ "Row-scoped actions"): +/// each button is declared once, and each click reads the row it was raised from with +/// ctx.RowAs<TodoEntry>() β€” the row as the person saw it, never one re-read by +/// position. A click with no row acts on nothing. +/// +/// Writes go to the hub that owns the todo data source (the area's own hub) as a +/// β€” todos are data-source entities, not mesh nodes. +/// +public static class TodoRowActions +{ + /// The width of every Todo menu button. + public const string MenuWidth = "150px"; + + /// The primary action of the clicked row's todo, chosen by its status as rendered. + public static Task OnPrimary(UiActionContext ctx, LayoutAreaHost host) + { + if (ctx.RowAs() is { Item: { } todo }) + SetStatus(host, todo, todo.Status switch + { + TodoStatus.Pending => TodoStatus.InProgress, + TodoStatus.InProgress => TodoStatus.Completed, + TodoStatus.Completed => TodoStatus.InProgress, + _ => TodoStatus.Pending, + }); + return Task.CompletedTask; + } + + /// Moves the clicked row's todo to . + public static Task OnSetStatus(UiActionContext ctx, LayoutAreaHost host, TodoStatus status) + { + if (ctx.RowAs() is { Item: { } todo }) + SetStatus(host, todo, status); + return Task.CompletedTask; + } + + /// Deletes the clicked row's todo. + public static Task OnDelete(UiActionContext ctx, LayoutAreaHost host) + { + if (ctx.RowAs() is { Item: { } todo }) + Post(host, new DataChangeRequest().WithDeletions(todo)); + return Task.CompletedTask; + } + + /// Assigns the clicked row's todo to . + public static Task OnAssign(UiActionContext ctx, LayoutAreaHost host, string person) + { + if (ctx.RowAs() is { Item: { } todo }) + Post(host, new DataChangeRequest().WithUpdates(todo with { ResponsiblePerson = person, UpdatedAt = DateTime.UtcNow })); + return Task.CompletedTask; + } + + /// Opens the edit dialog for the clicked row's todo. + public static Task OnEdit(UiActionContext ctx, LayoutAreaHost host) + { + if (ctx.RowAs() is { Item: { } todo }) + ShowEditDialog(host, todo); + return Task.CompletedTask; + } + + /// Opens the overdue-reminder dialog for the clicked row's todo. + public static Task OnReminder(UiActionContext ctx, LayoutAreaHost host) + { + if (ctx.RowAs() is { Item: { DueDate: not null } todo }) + ShowReminderDialog(host, todo); + return Task.CompletedTask; + } + + /// + /// The clicked heading row's group action, over the group AS RENDERED (). + /// An unknown action is logged and does nothing β€” never a default that means something. + /// + public static Task OnGroup(UiActionContext ctx, LayoutAreaHost host) + { + if (ctx.RowAs() is not { GroupAction.Length: > 0 } row || row.Group.IsEmpty) + return Task.CompletedTask; + + switch (row.GroupAction) + { + case TodoGroupActions.Start: + SetStatus(host, row.Group, TodoStatus.InProgress); + break; + case TodoGroupActions.Complete: + SetStatus(host, row.Group, TodoStatus.Completed); + break; + case TodoGroupActions.Delete: + Post(host, new DataChangeRequest().WithDeletions(row.Group.ToArray())); + break; + case TodoGroupActions.AutoAssign: + var team = ResponsiblePersons.AvailablePersons; + Post(host, new DataChangeRequest().WithUpdates(row.Group.Select((todo, i) => + todo with { ResponsiblePerson = team[i % team.Length], UpdatedAt = DateTime.UtcNow }))); + break; + case TodoGroupActions.AssignFirst when row.Person is { Length: > 0 } person: + Post(host, new DataChangeRequest().WithUpdates( + row.Group[0] with { ResponsiblePerson = person, UpdatedAt = DateTime.UtcNow })); + break; + default: + host.Hub.ServiceProvider.GetRequiredService().CreateLogger(typeof(TodoRowActions)) + .LogWarning("Todo row {Key} carries group action {Action}, which no handler claims; nothing done.", + row.Key, row.GroupAction); + break; + } + return Task.CompletedTask; + } + + private static void SetStatus(LayoutAreaHost host, TodoItem todo, TodoStatus status) + => Post(host, new DataChangeRequest().WithUpdates(todo with { Status = status, UpdatedAt = DateTime.UtcNow })); + + private static void SetStatus(LayoutAreaHost host, IEnumerable todos, TodoStatus status) + => Post(host, new DataChangeRequest().WithUpdates( + todos.Select(todo => todo with { Status = status, UpdatedAt = DateTime.UtcNow }))); + + private static void Post(LayoutAreaHost host, DataChangeRequest request) + => host.Hub.Post(request, o => o.WithTarget(host.Hub.Address)); + + // ── Dialogs ──────────────────────────────────────────────────────────────────────────────── + + /// Creates a todo and opens the dialog editing it; Cancel deletes it again. + public static void ShowNewTodoDialog(LayoutAreaHost host) + { + var todo = new TodoItem + { + Id = Guid.NewGuid().AsString(), + Title = "", + Description = "", + Status = TodoStatus.Pending, + Category = "General", + ResponsiblePerson = ResponsiblePersons.GetCurrentUser(), + DueDate = DateTime.Now.AddDays(7), + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + const string dataId = "NewTodoData"; + + var form = Controls.Stack + .WithView(Controls.H5(host.Localize("todo.dialog.create")) + .WithStyle(style => style.WithWidth("100%").WithTextAlign("center"))) + .WithView(host.Edit(todo, dataId) + .WithStyle(style => style.WithWidth("100%").WithDisplay("block")), dataId) + .WithView(Controls.Stack + .WithView(Controls.Button(host.Localize("todo.dialog.save")) + .WithClickAction(_ => + { + // The editor saves as you type β€” Save only closes. + host.UpdateArea(DialogControl.DialogArea, null!); + return Task.CompletedTask; + })) + .WithView(Controls.Button(host.Localize("todo.dialog.cancel")) + .WithClickAction(_ => + { + Post(host, new DataChangeRequest { Deletions = [todo] }); + host.UpdateArea(DialogControl.DialogArea, null!); + return Task.CompletedTask; + })) + .WithOrientation(Orientation.Horizontal) + .WithHorizontalGap(10) + .WithStyle(style => style.WithJustifyContent("center").WithWidth("100%"))) + .WithVerticalGap(15) + .WithStyle(style => style.WithWidth("100%").WithDisplay("block").WithMargin("0 auto")); + + host.UpdateArea(DialogControl.DialogArea, + Controls.Dialog(form, host.Localize("todo.dialog.create")).WithSize("M").WithClosable(false)); + } + + /// Opens the dialog editing (the editor saves as you type). + public static void ShowEditDialog(LayoutAreaHost host, TodoItem todo) + { + var dataId = $"EditTodoData_{todo.Id}"; + var form = Controls.Stack + .WithView(Controls.H5(host.Localize("todo.dialog.edit")) + .WithStyle(style => style.WithWidth("100%").WithTextAlign("center"))) + .WithView(host.Edit(todo, dataId) + .WithStyle(style => style.WithWidth("100%").WithDisplay("block")), dataId) + .WithView(Controls.Stack + .WithView(Controls.Button(host.Localize("todo.dialog.done")) + .WithClickAction(_ => + { + host.UpdateArea(DialogControl.DialogArea, null!); + return Task.CompletedTask; + })) + .WithOrientation(Orientation.Horizontal) + .WithHorizontalGap(10) + .WithStyle(style => style.WithJustifyContent("center").WithWidth("100%"))) + .WithVerticalGap(15) + .WithStyle(style => style.WithWidth("100%").WithDisplay("block").WithMargin("0 auto")); + + host.UpdateArea(DialogControl.DialogArea, + Controls.Dialog(form, host.Localize("todo.dialog.edit")).WithSize("M").WithClosable(false)); + } + + /// Opens the overdue-reminder dialog for . + public static void ShowReminderDialog(LayoutAreaHost host, TodoItem todo) + { + var due = todo.DueDate ?? DateTime.Now; + var dueText = due.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture); + var body = host.Localize("todo.reminder.body", todo.ResponsiblePerson, todo.Title, todo.Description, dueText); + var form = Controls.Stack + .WithView(Controls.H5(host.Localize("todo.reminder.heading")) + .WithStyle(style => style.WithWidth("100%").WithTextAlign("center"))) + .WithView(Controls.Markdown(host.Localize("todo.reminder.task", todo.Title))) + .WithView(Controls.Markdown(host.Localize("todo.reminder.assigned", + TodoProjections.DisplayName(todo.ResponsiblePerson, host.Localize)))) + .WithView(Controls.Markdown(host.Localize("todo.reminder.due", dueText, Math.Abs((DateTime.Now - due).Days))) + .WithStyle(style => style.WithColor("var(--color-danger-fg)"))) + .WithView(Controls.Markdown(host.Localize("todo.reminder.emailText"))) + .WithView(Controls.Markdown(string.Join("\n", body.Split('\n').Select(l => "> " + l)))) + .WithView(Controls.Stack + .WithView(Controls.Button(host.Localize("todo.action.sendReminder")) + .WithClickAction(_ => + { + // A sample: a real application would send the reminder here. + host.Hub.ServiceProvider.GetRequiredService().CreateLogger(typeof(TodoRowActions)) + .LogInformation("Overdue reminder for todo {Id} to {Person}", todo.Id, todo.ResponsiblePerson); + host.UpdateArea(DialogControl.DialogArea, null!); + return Task.CompletedTask; + })) + .WithView(Controls.Button(host.Localize("todo.dialog.cancel")) + .WithClickAction(_ => + { + host.UpdateArea(DialogControl.DialogArea, null!); + return Task.CompletedTask; + })) + .WithOrientation(Orientation.Horizontal) + .WithHorizontalGap(10) + .WithStyle(style => style.WithJustifyContent("center").WithWidth("100%"))) + .WithStyle(style => style.WithWidth("100%").WithDisplay("block").WithMargin("0 auto")); + + host.UpdateArea(DialogControl.DialogArea, + Controls.Dialog(form, host.Localize("todo.reminder.title")).WithSize("M").WithClosable(false)); + } +} diff --git a/samples/Todo/MeshWeaver.Todo/MeshWeaver.Todo.csproj b/samples/Todo/MeshWeaver.Todo/MeshWeaver.Todo.csproj index d47c62b11c..549e9e8bb0 100644 --- a/samples/Todo/MeshWeaver.Todo/MeshWeaver.Todo.csproj +++ b/samples/Todo/MeshWeaver.Todo/MeshWeaver.Todo.csproj @@ -15,6 +15,11 @@ + + + + + diff --git a/samples/Todo/MeshWeaver.Todo/TodoApplicationExtensions.cs b/samples/Todo/MeshWeaver.Todo/TodoApplicationExtensions.cs index e72ed5d890..fbffa0f069 100644 --- a/samples/Todo/MeshWeaver.Todo/TodoApplicationExtensions.cs +++ b/samples/Todo/MeshWeaver.Todo/TodoApplicationExtensions.cs @@ -25,7 +25,8 @@ public static MessageHubConfiguration ConfigureTodoApplication(this MessageHubCo // /api/content/{address}/Todo/{file} β€” published, still gated on Read (issue #587). .AddEmbeddedResourceContentCollection("Todo", typeof(TodoApplicationAttribute).Assembly, "Content", isStatic: true) .WithTypes( - typeof(TodoStatus) + typeof(TodoStatus), + typeof(TodoEntry) ) .AddData(data => data.AddSource(dataSource => diff --git a/src/MeshWeaver.Documentation/Data/GUI/DataBinding.md b/src/MeshWeaver.Documentation/Data/GUI/DataBinding.md index e9cd5cbc52..0f70cc1762 100644 --- a/src/MeshWeaver.Documentation/Data/GUI/DataBinding.md +++ b/src/MeshWeaver.Documentation/Data/GUI/DataBinding.md @@ -554,6 +554,8 @@ The rules: - **`MeshSearch` rows need none of this.** Each result renders through the node's OWN item area (`WithItemArea(…)`), so a button there lives on that node's hub, which already knows its path. - A `BlurEvent` carries the row the same way; inputs in a row need nothing extra, since they are bound by pointer to the row they edit. +**A worked example with sections, group actions and status-dependent menus β€” the Todo sample** (`samples/Todo`, `TodoLayoutAreas`). All seven areas used to wait for the todos and bake one menu per todo, its todo captured in the closure. Each is now ONE page (title, "Add New Todo", one bound list) fed by a pure projection (`TodoProjections`) into rows of ONE record, `TodoEntry`: a row is either a heading (a section title, a summary line, an empty state β€” optionally with a group action such as "Start All") or a todo (card + action menu, or the assignment menu). The parts a row lacks are hidden by a bound style, and so are the secondary actions its status does not offer β€” the template's shape never depends on data. Every button is declared once and reads `ctx.RowAs()` (`TodoRowActions`): an item action acts on the row's `Item`, and a group action on the row's `Group` exactly as rendered β€” what the closure used to capture now travels with the click. Pinned by `test/MeshWeaver.Layout.Test/TodoRowScopedActionsTest` (every area is a template; the page renders before any todo arrives; row k's Start / Delete / Assign act on todo k for every k, also after the list changed; a heading's group action acts on its group; a no-row negative control). + Where it is wired: core `MeshWeaver.Layout` (`RowContext`, `ClickedEvent.Row`, `UiActionContext.Row`, `DataGridControl.RenderSelf`) and `MeshWeaver.Mesh.Contract` (`RowAs`); the Blazor views in MeshWeaver.Plugins cascade the row (`ItemTemplate`, `DataGridView`) and `BlazorView` stamps it on every click and blur. Pinned by `test/MeshWeaver.Layout.Test/RowScopedClickActionTest` (N rows, row k acts on row k; a no-row negative control; the list changing between render and click; a grid template column) and, for the client half, Plugins' `RowScopedActionsFromViewsTest`. --- diff --git a/src/MeshWeaver.Messaging.Hub/Localization/strings.de.json b/src/MeshWeaver.Messaging.Hub/Localization/strings.de.json index ed7a1704e0..93f9aaf7bd 100644 --- a/src/MeshWeaver.Messaging.Hub/Localization/strings.de.json +++ b/src/MeshWeaver.Messaging.Hub/Localization/strings.de.json @@ -1943,5 +1943,91 @@ "serviceIdentities.noTokens": "Keine Tokens ausgestellt.", "serviceIdentities.rotate": "Erneuern", "serviceIdentities.status.active": "Aktiv", - "serviceIdentities.status.revoked": "Widerrufen" + "serviceIdentities.status.revoked": "Widerrufen", + "todo.addNew": "βž• Neue Aufgabe", + "todo.emptyAddHint": "*Keine Aufgaben gefunden. Klicken Sie auf β€žNeue Aufgabeβ€œ, um zu beginnen!*", + "todo.none": "*Keine Aufgaben gefunden.*", + "todo.sectionCount": "{0} ({1})", + "todo.unassignedStatus": "Nicht zugewiesen: {0}", + "todo.uncategorized": "Ohne Kategorie", + "todo.you": "🟒 {0} (Sie)", + "todo.status.pending": "Ausstehend", + "todo.status.inProgress": "In Arbeit", + "todo.status.completed": "Erledigt", + "todo.status.cancelled": "Abgebrochen", + "todo.due.overdue": "🚨 *{0} (ΓΌberfΓ€llig)*", + "todo.due.today": "⏰ *FΓ€llig {0}*", + "todo.due.on": "πŸ“… *{0}*", + "todo.action.start": "▢️ Starten", + "todo.action.done": "βœ… Fertig", + "todo.action.reopen": "πŸ”„ Wieder ΓΆffnen", + "todo.action.restore": "πŸ”„ Wiederherstellen", + "todo.action.edit": "✏️ Bearbeiten", + "todo.action.complete": "βœ… Abschliessen", + "todo.action.pause": "⏸️ Pausieren", + "todo.action.cancel": "❌ Abbrechen", + "todo.action.delete": "πŸ—‘οΈ LΓΆschen", + "todo.action.sendReminder": "πŸ“§ Erinnerung senden", + "todo.action.assign": "πŸ‘₯ Zuweisen", + "todo.group.startAll": "▢️ Alle starten", + "todo.group.closeAll": "⏸️ Alle schliessen", + "todo.group.archiveAll": "πŸ“¦ Alle archivieren", + "todo.group.deleteAll": "πŸ—‘οΈ Alle lΓΆschen", + "todo.group.completeAll": "βœ… Alle abschliessen", + "todo.group.autoAssignAll": "πŸ‘₯ Alle automatisch zuweisen", + "todo.group.autoAssign": "🎯 Automatisch zuweisen", + "todo.group.assignOne": "πŸ“₯ Zuweisen", + "todo.group.startAllOverdue": "▢️ Alle ΓΌberfΓ€lligen starten", + "todo.group.startAllToday": "▢️ Alle heutigen starten", + "todo.allItems.title": "πŸ“ Aufgabenliste mit Aktionen", + "todo.byCategory.title": "πŸ“‚ Aufgabenkategorien", + "todo.byCategory.header": "πŸ“ {0} ({1}) - {2}βœ… {3}πŸ”„ {4}⏳", + "todo.summary.title": "πŸ“Š Aufgaben-Dashboard", + "todo.summary.total": "**Aufgaben insgesamt:** {0}", + "todo.summary.statusOverview": "πŸ“ˆ StatusΓΌbersicht", + "todo.summary.statusLine": "{0} **{1}**: {2} ({3} %)", + "todo.summary.dueInsights": "⏰ FΓ€lligkeiten", + "todo.summary.overdue": "🚨 **ÜberfΓ€llig**: {0}", + "todo.summary.dueToday": "⏰ **Heute fΓ€llig**: {0}", + "todo.summary.dueWeek": "πŸ“… **Diese Woche fΓ€llig**: {0}", + "todo.summary.responsibility": "🟒 ZustΓ€ndigkeiten", + "todo.summary.mine": "🟒 **Meine Aufgaben ({0})**: {1}", + "todo.summary.others": "πŸ«‚ **Aufgaben anderer**: {0}", + "todo.summary.unassigned": "πŸ“‹ **Nicht zugewiesene Aufgaben**: {0}", + "todo.planning.title": "🎯 Planung und Zuweisung", + "todo.planning.empty": "*Keine Aufgaben gefunden. Legen Sie Aufgaben an, um mit der Planung zu beginnen!*", + "todo.planning.workload": "πŸ‘₯ Auslastung des Teams ({0} zugewiesen)", + "todo.planning.member": "{0} **{1}**: {2} aktiv", + "todo.planning.memberOverdue": " 🚨 {0} ΓΌberfΓ€llig", + "todo.planning.unassigned": "πŸ“‹ Nicht zugewiesene Aufgaben ({0})", + "todo.planning.task": "{0} **{1}** - `{2}`", + "todo.planning.taskDue": " *FΓ€llig: {0}*", + "todo.myTasks.title": "🟒 Meine aktiven Aufgaben ({0})", + "todo.myTasks.empty": "πŸŽ‰ **Alles erledigt!** Ihnen sind keine aktiven Aufgaben zugewiesen.", + "todo.myTasks.summary": "**{0} aktive Aufgaben** β€’ {1} in Arbeit", + "todo.myTasks.overdue": " β€’ 🚨 {0} ΓΌberfΓ€llig", + "todo.myTasks.dueToday": " β€’ ⏰ {0} heute fΓ€llig", + "todo.myTasks.urgent": "🚨 Dringend (ΓΌberfΓ€llig/heute fΓ€llig)", + "todo.myTasks.tomorrow": "⏰ Morgen fΓ€llig", + "todo.myTasks.upcoming": "πŸ“… DemnΓ€chst", + "todo.backlog.title": "πŸ“‹ Nicht zugewiesene Aufgaben", + "todo.backlog.empty": "πŸŽ‰ **Alle Aufgaben sind zugewiesen!** Keine offenen Aufgaben ohne ZustΓ€ndige.", + "todo.focus.title": "🎯 Fokus fΓΌr heute", + "todo.focus.summary": "**ÜberfΓ€llig**: {0} β€’ **Heute fΓ€llig**: {1} β€’ **Laufend**: {2}", + "todo.focus.empty": "πŸŽ‰ **Alles erledigt!** Keine dringenden Aufgaben und keine laufende Arbeit.", + "todo.focus.overdue": "🚨 ÜberfΓ€llig", + "todo.focus.dueToday": "⏰ Heute fΓ€llig", + "todo.focus.ongoing": "πŸ“‹ Laufend", + "todo.dialog.create": "Neue Aufgabe anlegen", + "todo.dialog.save": "πŸ’Ύ Aufgabe speichern", + "todo.dialog.cancel": "❌ Abbrechen", + "todo.dialog.edit": "Aufgabe bearbeiten", + "todo.dialog.done": "πŸ’Ύ Fertig", + "todo.reminder.title": "Erinnerung: ΓΌberfΓ€llig", + "todo.reminder.heading": "Erinnerung zur ΓΌberfΓ€lligen Aufgabe senden", + "todo.reminder.task": "**Aufgabe:** {0}", + "todo.reminder.assigned": "**Zugewiesen an:** {0}", + "todo.reminder.due": "**FΓ€llig:** {0} ({1} Tage ΓΌberfΓ€llig)", + "todo.reminder.emailText": "**E-Mail-Text:**", + "todo.reminder.body": "Hallo {0}\n\nDies ist eine Erinnerung, dass die folgende Aufgabe ΓΌberfΓ€llig ist:\n\nAufgabe: {1}\nBeschreibung: {2}\nFΓ€llig am: {3}\n\nBitte erledigen Sie diese Aufgabe so bald wie mΓΆglich.\n\nDanke!" } diff --git a/src/MeshWeaver.Messaging.Hub/Localization/strings.en.json b/src/MeshWeaver.Messaging.Hub/Localization/strings.en.json index ea39406ecd..f5165c3da3 100644 --- a/src/MeshWeaver.Messaging.Hub/Localization/strings.en.json +++ b/src/MeshWeaver.Messaging.Hub/Localization/strings.en.json @@ -1943,5 +1943,91 @@ "serviceIdentities.noTokens": "No tokens issued.", "serviceIdentities.rotate": "Rotate", "serviceIdentities.status.active": "Active", - "serviceIdentities.status.revoked": "Revoked" + "serviceIdentities.status.revoked": "Revoked", + "todo.addNew": "βž• Add New Todo", + "todo.emptyAddHint": "*No todo items found. Click 'Add New Todo' to get started!*", + "todo.none": "*No todo items found.*", + "todo.sectionCount": "{0} ({1})", + "todo.unassignedStatus": "Unassigned {0}", + "todo.uncategorized": "Uncategorized", + "todo.you": "🟒 {0} (You)", + "todo.status.pending": "Pending", + "todo.status.inProgress": "In Progress", + "todo.status.completed": "Completed", + "todo.status.cancelled": "Cancelled", + "todo.due.overdue": "🚨 *{0} (Overdue)*", + "todo.due.today": "⏰ *Due {0}*", + "todo.due.on": "πŸ“… *{0}*", + "todo.action.start": "▢️ Start", + "todo.action.done": "βœ… Done", + "todo.action.reopen": "πŸ”„ Reopen", + "todo.action.restore": "πŸ”„ Restore", + "todo.action.edit": "✏️ Edit", + "todo.action.complete": "βœ… Complete", + "todo.action.pause": "⏸️ Pause", + "todo.action.cancel": "❌ Cancel", + "todo.action.delete": "πŸ—‘οΈ Delete", + "todo.action.sendReminder": "πŸ“§ Send Reminder", + "todo.action.assign": "πŸ‘₯ Assign", + "todo.group.startAll": "▢️ Start All", + "todo.group.closeAll": "⏸️ Close All", + "todo.group.archiveAll": "πŸ“¦ Archive All", + "todo.group.deleteAll": "πŸ—‘οΈ Delete All", + "todo.group.completeAll": "βœ… Complete All", + "todo.group.autoAssignAll": "πŸ‘₯ Auto-Assign All", + "todo.group.autoAssign": "🎯 Auto-Assign", + "todo.group.assignOne": "πŸ“₯ Assign", + "todo.group.startAllOverdue": "▢️ Start All Overdue", + "todo.group.startAllToday": "▢️ Start All Today", + "todo.allItems.title": "πŸ“ Todo List with Actions", + "todo.byCategory.title": "πŸ“‚ Todo Categories", + "todo.byCategory.header": "πŸ“ {0} ({1}) - {2}βœ… {3}πŸ”„ {4}⏳", + "todo.summary.title": "πŸ“Š Todo Dashboard", + "todo.summary.total": "**Total Items:** {0}", + "todo.summary.statusOverview": "πŸ“ˆ Status Overview", + "todo.summary.statusLine": "{0} **{1}**: {2} ({3}%)", + "todo.summary.dueInsights": "⏰ Due Date Insights", + "todo.summary.overdue": "🚨 **Overdue**: {0}", + "todo.summary.dueToday": "⏰ **Due Today**: {0}", + "todo.summary.dueWeek": "πŸ“… **Due This Week**: {0}", + "todo.summary.responsibility": "🟒 Responsibility Overview", + "todo.summary.mine": "🟒 **My Tasks ({0})**: {1}", + "todo.summary.others": "πŸ«‚ **Others' Tasks**: {0}", + "todo.summary.unassigned": "πŸ“‹ **Unassigned Tasks**: {0}", + "todo.planning.title": "🎯 Task Planning & Assignment", + "todo.planning.empty": "*No tasks found. Create some tasks to start planning!*", + "todo.planning.workload": "πŸ‘₯ Team Workload ({0} assigned)", + "todo.planning.member": "{0} **{1}**: {2} active", + "todo.planning.memberOverdue": " 🚨 {0} overdue", + "todo.planning.unassigned": "πŸ“‹ Unassigned Tasks ({0})", + "todo.planning.task": "{0} **{1}** - `{2}`", + "todo.planning.taskDue": " *Due: {0}*", + "todo.myTasks.title": "🟒 My Active Tasks ({0})", + "todo.myTasks.empty": "πŸŽ‰ **All caught up!** You have no active tasks assigned.", + "todo.myTasks.summary": "**{0} active tasks** β€’ {1} in progress", + "todo.myTasks.overdue": " β€’ 🚨 {0} overdue", + "todo.myTasks.dueToday": " β€’ ⏰ {0} due today", + "todo.myTasks.urgent": "🚨 Urgent (Overdue/Due Today)", + "todo.myTasks.tomorrow": "⏰ Due Tomorrow", + "todo.myTasks.upcoming": "πŸ“… Upcoming", + "todo.backlog.title": "πŸ“‹ Unassigned Tasks", + "todo.backlog.empty": "πŸŽ‰ **All tasks are assigned!** No unassigned tasks found.", + "todo.focus.title": "🎯 Today's Focus", + "todo.focus.summary": "**Overdue**: {0} β€’ **Due today**: {1} β€’ **Ongoing**: {2}", + "todo.focus.empty": "πŸŽ‰ **All caught up!** No urgent tasks or active work.", + "todo.focus.overdue": "🚨 Overdue", + "todo.focus.dueToday": "⏰ Due Today", + "todo.focus.ongoing": "πŸ“‹ Ongoing", + "todo.dialog.create": "Create New Todo", + "todo.dialog.save": "πŸ’Ύ Save Todo", + "todo.dialog.cancel": "❌ Cancel", + "todo.dialog.edit": "Edit Todo", + "todo.dialog.done": "πŸ’Ύ Done", + "todo.reminder.title": "Overdue Reminder", + "todo.reminder.heading": "Send Overdue Reminder", + "todo.reminder.task": "**Task:** {0}", + "todo.reminder.assigned": "**Assigned to:** {0}", + "todo.reminder.due": "**Due:** {0} ({1} days overdue)", + "todo.reminder.emailText": "**Email Text:**", + "todo.reminder.body": "Hi {0},\n\nThis is a reminder that the following task is overdue:\n\nTask: {1}\nDescription: {2}\nDue Date: {3}\n\nPlease complete this task as soon as possible.\n\nThanks!" } diff --git a/test/LayoutAreaDataBakeSites.allow b/test/LayoutAreaDataBakeSites.allow index 993879f7bd..f00b572a0a 100644 --- a/test/LayoutAreaDataBakeSites.allow +++ b/test/LayoutAreaDataBakeSites.allow @@ -20,7 +20,6 @@ samples/Graph/Data/Northwind/Product/Source/ProductNodeLayoutAreas.cs 2 samples/Graph/Data/Northwind/ReportsCatalog/Source/ReportsCatalogLayoutAreas.cs 1 samples/Graph/Data/Northwind/Supplier/Source/SupplierNodeLayoutAreas.cs 2 samples/Graph/Data/PythonDemo/PrimeReport/Source/PrimeReportLayoutAreas.cs 1 -samples/Todo/MeshWeaver.Todo/LayoutAreas/TodoLayoutAreas.cs 7 src/MeshWeaver.ContentCollections/ContentLayoutArea.cs 1 src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Post/Source/SocialMediaPostLayoutAreas.cs 1 src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Profile/Source/SocialMediaProfileLayoutAreas.cs 1 diff --git a/test/MeshWeaver.Documentation.Test/LayoutAreaDataBakeRatchetGuard.cs b/test/MeshWeaver.Documentation.Test/LayoutAreaDataBakeRatchetGuard.cs index 231aaede77..9c00d3a36f 100644 --- a/test/MeshWeaver.Documentation.Test/LayoutAreaDataBakeRatchetGuard.cs +++ b/test/MeshWeaver.Documentation.Test/LayoutAreaDataBakeRatchetGuard.cs @@ -44,7 +44,7 @@ public class LayoutAreaDataBakeRatchetGuard(ITestOutputHelper output) private const string AllowFileName = "LayoutAreaDataBakeSites.allow"; /// The seeded inventory's size. Lower it in the same change that lowers a line. - private const int TotalBudget = 73; + private const int TotalBudget = 66; /// Reads that make a unit wait on data. internal static readonly Regex LoadPattern = new( diff --git a/test/MeshWeaver.Layout.Test/MeshWeaver.Layout.Test.csproj b/test/MeshWeaver.Layout.Test/MeshWeaver.Layout.Test.csproj index 37878711b5..5e08911024 100644 --- a/test/MeshWeaver.Layout.Test/MeshWeaver.Layout.Test.csproj +++ b/test/MeshWeaver.Layout.Test/MeshWeaver.Layout.Test.csproj @@ -10,6 +10,9 @@ + + diff --git a/test/MeshWeaver.Layout.Test/TodoRowScopedActionsTest.cs b/test/MeshWeaver.Layout.Test/TodoRowScopedActionsTest.cs new file mode 100644 index 0000000000..95780e4665 --- /dev/null +++ b/test/MeshWeaver.Layout.Test/TodoRowScopedActionsTest.cs @@ -0,0 +1,359 @@ +using System.Collections; +using System.Collections.Immutable; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using System.Reflection; +using System.Text.Json; +using MeshWeaver.Data; +using MeshWeaver.Fixture; +using MeshWeaver.Layout.Composition; +using MeshWeaver.Messaging; +using MeshWeaver.Todo.Domain; +using MeshWeaver.Todo.LayoutAreas; +using Xunit; + +namespace MeshWeaver.Layout.Test; + +/// +/// The Todo sample's areas are TEMPLATES (Doc/GUI/DataBinding β†’ "Templates first, data later") whose +/// per-row buttons are ROW-SCOPED (β†’ "Row-scoped actions"). Before, each area waited for the todos on +/// the hub and baked one button per todo with the todo captured in its closure; now the page renders +/// at once, the rows bind to a projection, and each button exists ONCE in the row template β€” the click +/// carries the row it came from. These tests pin both halves: the page renders with no data, and +/// clicking a button in row k acts on the todo of row k, for every k, also after the list has changed. +/// +public class TodoRowScopedActionsTest(ITestOutputHelper output) : HubTestBase(output) +{ + private const string NoDataArea = "NoData"; + private const string Assignee = "Jordan Smith"; + + private static TodoItem Todo(string id, string person = Assignee, TodoStatus status = TodoStatus.Pending) => new() + { + Id = id, + Title = $"Task {id}", + Category = "Work", + ResponsiblePerson = person, + Status = status, + DueDate = DateTime.Now.Date.AddDays(3), + CreatedAt = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddMinutes(id[0]), + }; + + /// Four pending todos assigned to someone, and three unassigned ones. + private static readonly ImmutableList Initial = + [ + Todo("a"), Todo("b"), Todo("c"), Todo("d"), + Todo("u", "Unassigned"), Todo("v", "Unassigned"), Todo("w", "Unassigned"), + ]; + + private static readonly string[] Areas = + [ + nameof(TodoLayoutAreas.AllItems), nameof(TodoLayoutAreas.TodosByCategory), nameof(TodoLayoutAreas.Summary), + nameof(TodoLayoutAreas.Planning), nameof(TodoLayoutAreas.MyTasks), nameof(TodoLayoutAreas.Backlog), + nameof(TodoLayoutAreas.TodaysFocus), + ]; + + /// What each area returned on its first render, captured as the area returned it. + private readonly ReplaySubject<(string Area, UiControl Control)> rendered = new(); + + /// + protected override MessageHubConfiguration ConfigureHost(MessageHubConfiguration configuration) + => base.ConfigureHost(configuration) + .WithPostingIdentity(PostingIdentity.System) + .WithRoutes(r => r.RouteAddress(ClientType, (_, d) => d.Package())) + .WithTypes(typeof(TodoStatus), typeof(TodoEntry)) + .AddData(data => data.AddSource(ds => ds.WithType(t => t + .WithKey(todo => todo.Id) + .WithInitialData(Initial)))) + .AddLayout(layout => Areas + .Aggregate(layout, (l, area) => l.WithView(area, (host, ctx) => Capture(area, Render(area, host, ctx)))) + // The template with a feed that NEVER answers: the page must still render. + .WithView(NoDataArea, (host, _) => TodoLayoutAreas.Page(host, "Todo", Typography.H4, withAdd: true, + Observable.Never>()))); + + /// + protected override MessageHubConfiguration ConfigureClient(MessageHubConfiguration configuration) + => base.ConfigureClient(configuration).AddLayoutClient(d => d); + + private static UiControl Render(string area, LayoutAreaHost host, RenderingContext ctx) => area switch + { + nameof(TodoLayoutAreas.AllItems) => TodoLayoutAreas.AllItems(host, ctx), + nameof(TodoLayoutAreas.TodosByCategory) => TodoLayoutAreas.TodosByCategory(host, ctx), + nameof(TodoLayoutAreas.Summary) => TodoLayoutAreas.Summary(host, ctx), + nameof(TodoLayoutAreas.Planning) => TodoLayoutAreas.Planning(host, ctx), + nameof(TodoLayoutAreas.MyTasks) => TodoLayoutAreas.MyTasks(host, ctx), + nameof(TodoLayoutAreas.Backlog) => TodoLayoutAreas.Backlog(host, ctx), + _ => TodoLayoutAreas.TodaysFocus(host, ctx), + }; + + private UiControl Capture(string area, UiControl control) + { + rendered.OnNext((area, control)); + return control; + } + + // ── Templates first ──────────────────────────────────────────────────────────────────────── + + /// Every Todo area returns a control tree with no deferred view: nothing in it renders + /// only once the todos have arrived; the rows are ONE bound list. + [HubFact] + public async Task EveryTodoArea_IsATemplate() + { + foreach (var area in Areas) + { + var stream = Open(area); + await stream.GetControlStream(area).Should().Within(10.Seconds()).Match( + c => c is LayoutGridControl, $"{area} renders its page", TestContext.Current.CancellationToken); + var (_, control) = (await rendered.Where(r => r.Area == area).Should().Within(10.Seconds()) + .Emit($"{area} was rendered", TestContext.Current.CancellationToken))!; + + EveryViewIsStatic(control); + Descendants(control).OfType().Should().ContainSingle( + $"{area}'s rows are ONE bound list, not a control per todo"); + } + } + + /// The page renders β€” title, Add action, and the row template β€” while its feed has not + /// answered at all: its first emission waits on no data. + [HubFact] + public async Task ThePageRenders_BeforeAnyTodoHasArrived() + { + var stream = Open(NoDataArea); + var list = await ListArea(stream, NoDataArea); + await stream.GetControlStream($"{list}/{ItemTemplateControl.ViewArea}").Should().Within(10.Seconds()).Match( + c => c is LayoutGridControl, + "the row template is part of the page before any row exists", + TestContext.Current.CancellationToken); + } + + // ── Row-scoped actions ───────────────────────────────────────────────────────────────────── + + /// The primary action ("Start") in row k starts the todo of row k β€” for every k, each + /// click with the row as it was first rendered β€” and touches no other todo. + [HubFact] + public async Task ThePrimaryActionInRowK_StartsTodoK() + { + var area = nameof(TodoLayoutAreas.AllItems); + var stream = Open(area); + var template = await Template(stream, area); + var primary = await Child(stream, await Child(stream, await Child(stream, template, 3), 0), 0); + var rows = await ItemRows(stream, area, r => r.Item?.ResponsiblePerson == Assignee, 4); + + for (var k = 0; k < rows.Length; k++) + { + Click(stream, primary, rows[k].Row); + var started = rows.Take(k + 1).Select(r => r.Entry.Item!.Id).ToImmutableHashSet(); + await Todos().Should().Within(10.Seconds()).Match( + todos => todos.Where(t => t.ResponsiblePerson == Assignee) + .All(t => (t.Status == TodoStatus.InProgress) == started.Contains(t.Id)), + $"Start in row {k} starts todo {rows[k].Entry.Item!.Id} and no other", + TestContext.Current.CancellationToken); + } + } + + /// The list changes between the render and the click β€” the todo above is deleted, so + /// another todo sits in the clicked slot β€” and Delete still deletes the todo that was clicked. + [HubFact] + public async Task AfterTheListChanged_DeleteStillActsOnTheClickedTodo() + { + var area = nameof(TodoLayoutAreas.AllItems); + var stream = Open(area); + var template = await Template(stream, area); + var primary = await Child(stream, await Child(stream, await Child(stream, template, 3), 0), 0); + var delete = await Child(stream, primary, 4); + var rows = await ItemRows(stream, area, r => r.Item?.ResponsiblePerson == Assignee, 4); + + Click(stream, delete, rows[1].Row); // "b" + await Todos().Should().Within(10.Seconds()).Match(t => t.All(x => x.Id != "b"), + "Delete in row 1 deletes b", TestContext.Current.CancellationToken); + await ItemRows(stream, area, r => r.Item?.ResponsiblePerson == Assignee, 3); + + Click(stream, delete, rows[2].Row); // "c" as first rendered; the slot now holds "d" + await Todos().Should().Within(10.Seconds()).Match( + t => t.All(x => x.Id != "c") && t.Any(x => x.Id == "d") && t.Any(x => x.Id == "a"), + "the person clicked c; d, which moved into its slot, is not theirs to delete", + TestContext.Current.CancellationToken); + } + + /// In the Backlog, team member j's entry in row k's assignment menu assigns todo k to + /// member j β€” for every k. + [HubFact] + public async Task TheAssignMenuInRowK_AssignsTodoK() + { + var area = nameof(TodoLayoutAreas.Backlog); + var stream = Open(area); + var template = await Template(stream, area); + var assignMenu = await Child(stream, await Child(stream, await Child(stream, template, 3), 1), 0); + var rows = await ItemRows(stream, area, r => r.Item is not null, 3); + + for (var k = 0; k < rows.Length; k++) + { + var member = ResponsiblePersons.AvailablePersons[k + 1]; + Click(stream, await Child(stream, assignMenu, k + 1), rows[k].Row); + var id = rows[k].Entry.Item!.Id; + await Todos().Should().Within(10.Seconds()).Match( + todos => todos.Single(t => t.Id == id).ResponsiblePerson == member, + $"member {k + 1} in row {k}'s menu assigns {id} to {member}", + TestContext.Current.CancellationToken); + } + } + + /// A section's group action acts on the group of THAT heading row: "Start All" on the + /// pending section starts exactly the four assigned todos and none of the unassigned ones. + [HubFact] + public async Task TheGroupActionOfAHeadingRow_ActsOnItsGroup() + { + var area = nameof(TodoLayoutAreas.AllItems); + var stream = Open(area); + var template = await Template(stream, area); + var group = await Child(stream, template, 1); + var heading = (await Entries(stream, area, 1)).Single(r => r.Entry.Key == $"assigned-{TodoStatus.Pending}"); + + Click(stream, group, heading.Row); + await Todos().Should().Within(10.Seconds()).Match( + todos => todos.All(t => (t.Status == TodoStatus.InProgress) == (t.ResponsiblePerson == Assignee)), + "Start All on the assigned-pending section starts its four todos and nothing else", + TestContext.Current.CancellationToken); + } + + /// NEGATIVE CONTROL: the same buttons clicked with no row act on nothing β€” the todo comes + /// from the row on the click and from nothing else. + [HubFact] + public async Task AClickWithNoRow_ActsOnNothing() + { + var area = nameof(TodoLayoutAreas.AllItems); + var stream = Open(area); + var template = await Template(stream, area); + var primary = await Child(stream, await Child(stream, await Child(stream, template, 3), 0), 0); + var group = await Child(stream, template, 1); + await ItemRows(stream, area, r => r.Item is not null, Initial.Count); + + Click(stream, primary, row: null); + Click(stream, group, row: null); + + await Todos().Where(todos => todos.Any(t => t.Status != TodoStatus.Pending)).Should().NotEmit( + 500.Milliseconds(), "with no row the actions cannot know which todo, and must not guess", + TestContext.Current.CancellationToken); + } + + // ── Projection ───────────────────────────────────────────────────────────────────────────── + + /// The projection decides which secondary actions a todo offers, by its status. + [Theory] + [InlineData(TodoStatus.Pending, true, false, true)] + [InlineData(TodoStatus.InProgress, false, true, true)] + [InlineData(TodoStatus.Completed, false, false, false)] + [InlineData(TodoStatus.Cancelled, false, false, false)] + public void TheRowOffersTheActionsItsStatusAllows(TodoStatus status, bool complete, bool pause, bool cancel) + { + var row = TodoProjections.ItemRow(Todo("x", status: status), English, TodoStyles.Card); + (row.CompleteStyle != TodoStyles.Hidden).Should().Be(complete); + (row.PauseStyle != TodoStyles.Hidden).Should().Be(pause); + (row.CancelStyle != TodoStyles.Hidden).Should().Be(cancel); + row.ActionsStyle.Should().NotBe(TodoStyles.Hidden); + row.AssignStyle.Should().Be(TodoStyles.Hidden); + row.HeadingStyle.Should().Be(TodoStyles.Hidden, "an item row shows no heading"); + } + + private static readonly TodoViewText English = + new((key, args) => LocalizationCatalog.Get(key, "en", args), System.Globalization.CultureInfo.InvariantCulture); + + // ── Harness ──────────────────────────────────────────────────────────────────────────────── + + private ISynchronizationStream Open(string area) + => GetClient().GetWorkspace().GetRemoteStream( + CreateHostAddress(), new LayoutAreaReference(area)); + + private IObservable> Todos() + => GetHost().GetWorkspace().GetStream()!.Select(t => (IReadOnlyCollection)(t ?? [])); + + /// The area of the page's row list (its last child). + private static async Task ListArea(ISynchronizationStream stream, string area) + { + var page = (LayoutGridControl)(await stream.GetControlStream(area).Should().Within(10.Seconds()).Match( + c => c is LayoutGridControl, "the page renders", TestContext.Current.CancellationToken))!; + return page.Areas.Last().Area.ToString()!; + } + + private static async Task Template(ISynchronizationStream stream, string area) + { + var template = $"{await ListArea(stream, area)}/{ItemTemplateControl.ViewArea}"; + await stream.GetControlStream(template).Should().Within(10.Seconds()).Match( + c => c is LayoutGridControl, "the row template renders once", TestContext.Current.CancellationToken); + return template; + } + + private static async Task Child(ISynchronizationStream stream, string area, int index) + { + var container = (IContainerControl)(await stream.GetControlStream(area).Should().Within(10.Seconds()).Match( + c => c is IContainerControl, $"{area} renders", TestContext.Current.CancellationToken))!; + return container.Areas.ElementAt(index).Area.ToString()!; + } + + /// The rows as the client's mirror holds them, once it holds at least + /// , each with the the client posts for it. + private async Task> Entries( + ISynchronizationStream stream, string area, int min) + { + var pointer = LayoutAreaReference.GetDataPointer(TodoLayoutAreas.EntriesId); + var rows = await stream.GetDataStream(new JsonPointerReference(pointer)) + .Should().Within(10.Seconds()).Match( + r => r.ValueKind == JsonValueKind.Array && r.GetArrayLength() >= min, + $"the client mirror holds {area}'s rows", TestContext.Current.CancellationToken); + return [.. rows.EnumerateArray().Select((r, i) => ( + r.Deserialize(GetClient().JsonSerializerOptions)!, + new RowContext { Pointer = $"{pointer}/{i}", Index = i, Value = r.Clone() }))]; + } + + private async Task> ItemRows( + ISynchronizationStream stream, string area, Func which, int count) + { + var pointer = LayoutAreaReference.GetDataPointer(TodoLayoutAreas.EntriesId); + await stream.GetDataStream(new JsonPointerReference(pointer)) + .Should().Within(10.Seconds()).Match( + r => r.ValueKind == JsonValueKind.Array && r.EnumerateArray() + .Count(e => which(e.Deserialize(GetClient().JsonSerializerOptions)!)) == count, + $"{area} shows {count} matching todo rows", TestContext.Current.CancellationToken); + return [.. (await Entries(stream, area, count)).Where(r => which(r.Entry))]; + } + + private static void Click(ISynchronizationStream stream, string area, RowContext? row) + => stream.SubmitUserAction(new ClickedEvent(area, stream.StreamId) { Row = row }, + actingUser: null, onRefused: null, onAccepted: null); + + private static void EveryViewIsStatic(UiControl root) + { + foreach (var control in Descendants(root)) + { + if (Member(control.GetType(), "Views")?.GetValue(control) is not IEnumerable views) + continue; + var deferred = views.OfType().Where(v => v is not UiControl).Select(v => v.GetType().Name).ToList(); + deferred.Should().BeEmpty( + $"{control.GetType().Name} carries {deferred.Count} deferred view(s) β€” a view that renders only once " + + "data has arrived. A template binds its data instead."); + } + } + + private static IEnumerable Descendants(UiControl root) + { + yield return root; + if (root is ItemTemplateControl template) + foreach (var d in Descendants(template.View)) + yield return d; + if (Member(root.GetType(), "Views")?.GetValue(root) is IEnumerable views) + foreach (var v in views) + if (v is UiControl child) + foreach (var d in Descendants(child)) + yield return d; + } + + private static PropertyInfo? Member(Type? t, string name) + { + for (; t is not null; t = t.BaseType) + { + var p = t.GetProperty(name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.DeclaredOnly); + if (p is not null) + return p; + } + return null; + } +} From 32e64a7c8147d8dab1e8d8759a911cc24697360c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= <6334612+rbuergi@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:13:56 +0200 Subject: [PATCH 3/3] review(todo): no null-forgiving operator in the row-scoped Todo sample or its test The five dialog closes pass null (UpdateArea takes object?); the test reads its rows, areas and streams through an asserting helper and pattern matches. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../LayoutAreas/TodoRowActions.cs | 10 ++--- .../TodoRowScopedActionsTest.cs | 38 ++++++++++++------- 2 files changed, 29 insertions(+), 19 deletions(-) diff --git a/samples/Todo/MeshWeaver.Todo/LayoutAreas/TodoRowActions.cs b/samples/Todo/MeshWeaver.Todo/LayoutAreas/TodoRowActions.cs index cfa352993b..bbe095a651 100644 --- a/samples/Todo/MeshWeaver.Todo/LayoutAreas/TodoRowActions.cs +++ b/samples/Todo/MeshWeaver.Todo/LayoutAreas/TodoRowActions.cs @@ -152,14 +152,14 @@ public static void ShowNewTodoDialog(LayoutAreaHost host) .WithClickAction(_ => { // The editor saves as you type β€” Save only closes. - host.UpdateArea(DialogControl.DialogArea, null!); + host.UpdateArea(DialogControl.DialogArea, null); return Task.CompletedTask; })) .WithView(Controls.Button(host.Localize("todo.dialog.cancel")) .WithClickAction(_ => { TodoLayoutAreas.Delete(host, [todo]); - host.UpdateArea(DialogControl.DialogArea, null!); + host.UpdateArea(DialogControl.DialogArea, null); return Task.CompletedTask; })) .WithOrientation(Orientation.Horizontal) @@ -185,7 +185,7 @@ public static void ShowEditDialog(LayoutAreaHost host, TodoItem todo) .WithView(Controls.Button(host.Localize("todo.dialog.done")) .WithClickAction(_ => { - host.UpdateArea(DialogControl.DialogArea, null!); + host.UpdateArea(DialogControl.DialogArea, null); return Task.CompletedTask; })) .WithOrientation(Orientation.Horizontal) @@ -221,13 +221,13 @@ public static void ShowReminderDialog(LayoutAreaHost host, TodoItem todo) // A sample: a real application would send the reminder here. host.Hub.ServiceProvider.GetRequiredService().CreateLogger(typeof(TodoRowActions)) .LogInformation("Overdue reminder for todo {Id} to {Person}", todo.Id, todo.ResponsiblePerson); - host.UpdateArea(DialogControl.DialogArea, null!); + host.UpdateArea(DialogControl.DialogArea, null); return Task.CompletedTask; })) .WithView(Controls.Button(host.Localize("todo.dialog.cancel")) .WithClickAction(_ => { - host.UpdateArea(DialogControl.DialogArea, null!); + host.UpdateArea(DialogControl.DialogArea, null); return Task.CompletedTask; })) .WithOrientation(Orientation.Horizontal) diff --git a/test/MeshWeaver.Layout.Test/TodoRowScopedActionsTest.cs b/test/MeshWeaver.Layout.Test/TodoRowScopedActionsTest.cs index 95780e4665..4dca272712 100644 --- a/test/MeshWeaver.Layout.Test/TodoRowScopedActionsTest.cs +++ b/test/MeshWeaver.Layout.Test/TodoRowScopedActionsTest.cs @@ -103,8 +103,8 @@ public async Task EveryTodoArea_IsATemplate() var stream = Open(area); await stream.GetControlStream(area).Should().Within(10.Seconds()).Match( c => c is LayoutGridControl, $"{area} renders its page", TestContext.Current.CancellationToken); - var (_, control) = (await rendered.Where(r => r.Area == area).Should().Within(10.Seconds()) - .Emit($"{area} was rendered", TestContext.Current.CancellationToken))!; + var (_, control) = await rendered.Where(r => r.Area == area).Should().Within(10.Seconds()) + .Emit($"{area} was rendered", TestContext.Current.CancellationToken); EveryViewIsStatic(control); Descendants(control).OfType().Should().ContainSingle( @@ -141,11 +141,11 @@ public async Task ThePrimaryActionInRowK_StartsTodoK() for (var k = 0; k < rows.Length; k++) { Click(stream, primary, rows[k].Row); - var started = rows.Take(k + 1).Select(r => r.Entry.Item!.Id).ToImmutableHashSet(); + var started = rows.Take(k + 1).Select(r => TodoOf(r.Entry).Id).ToImmutableHashSet(); await Todos().Should().Within(10.Seconds()).Match( todos => todos.Where(t => t.ResponsiblePerson == Assignee) .All(t => (t.Status == TodoStatus.InProgress) == started.Contains(t.Id)), - $"Start in row {k} starts todo {rows[k].Entry.Item!.Id} and no other", + $"Start in row {k} starts todo {TodoOf(rows[k].Entry).Id} and no other", TestContext.Current.CancellationToken); } } @@ -189,7 +189,7 @@ public async Task TheAssignMenuInRowK_AssignsTodoK() { var member = ResponsiblePersons.AvailablePersons[k + 1]; Click(stream, await Child(stream, assignMenu, k + 1), rows[k].Row); - var id = rows[k].Entry.Item!.Id; + var id = TodoOf(rows[k].Entry).Id; await Todos().Should().Within(10.Seconds()).Match( todos => todos.Single(t => t.Id == id).ResponsiblePerson == member, $"member {k + 1} in row {k}'s menu assigns {id} to {member}", @@ -263,15 +263,25 @@ private ISynchronizationStream Open(string area) => GetClient().GetWorkspace().GetRemoteStream( CreateHostAddress(), new LayoutAreaReference(area)); + /// The value, asserted present β€” the test fails here, by name, when it is not. + private static T Present(T? value) where T : class + { + Assert.NotNull(value); + return value; + } + + /// The todo a row carries; a heading row carries none, and a test that asks for it there fails. + private static TodoItem TodoOf(TodoEntry entry) => Present(entry.Item); + private IObservable> Todos() - => GetHost().GetWorkspace().GetStream()!.Select(t => (IReadOnlyCollection)(t ?? [])); + => Present(GetHost().GetWorkspace().GetStream()).Select(t => (IReadOnlyCollection)(t ?? [])); /// The area of the page's row list (its last child). private static async Task ListArea(ISynchronizationStream stream, string area) { - var page = (LayoutGridControl)(await stream.GetControlStream(area).Should().Within(10.Seconds()).Match( - c => c is LayoutGridControl, "the page renders", TestContext.Current.CancellationToken))!; - return page.Areas.Last().Area.ToString()!; + var page = Assert.IsType(await stream.GetControlStream(area).Should().Within(10.Seconds()).Match( + c => c is LayoutGridControl, "the page renders", TestContext.Current.CancellationToken)); + return Present(page.Areas.Last().Area.ToString()); } private static async Task Template(ISynchronizationStream stream, string area) @@ -284,9 +294,9 @@ await stream.GetControlStream(template).Should().Within(10.Seconds()).Match( private static async Task Child(ISynchronizationStream stream, string area, int index) { - var container = (IContainerControl)(await stream.GetControlStream(area).Should().Within(10.Seconds()).Match( - c => c is IContainerControl, $"{area} renders", TestContext.Current.CancellationToken))!; - return container.Areas.ElementAt(index).Area.ToString()!; + var container = Assert.IsAssignableFrom(await stream.GetControlStream(area).Should().Within(10.Seconds()).Match( + c => c is IContainerControl, $"{area} renders", TestContext.Current.CancellationToken)); + return Present(container.Areas.ElementAt(index).Area.ToString()); } /// The rows as the client's mirror holds them, once it holds at least @@ -300,7 +310,7 @@ private static async Task Child(ISynchronizationStream stre r => r.ValueKind == JsonValueKind.Array && r.GetArrayLength() >= min, $"the client mirror holds {area}'s rows", TestContext.Current.CancellationToken); return [.. rows.EnumerateArray().Select((r, i) => ( - r.Deserialize(GetClient().JsonSerializerOptions)!, + Present(r.Deserialize(GetClient().JsonSerializerOptions)), new RowContext { Pointer = $"{pointer}/{i}", Index = i, Value = r.Clone() }))]; } @@ -311,7 +321,7 @@ private static async Task Child(ISynchronizationStream stre await stream.GetDataStream(new JsonPointerReference(pointer)) .Should().Within(10.Seconds()).Match( r => r.ValueKind == JsonValueKind.Array && r.EnumerateArray() - .Count(e => which(e.Deserialize(GetClient().JsonSerializerOptions)!)) == count, + .Count(e => e.Deserialize(GetClient().JsonSerializerOptions) is { } entry && which(entry)) == count, $"{area} shows {count} matching todo rows", TestContext.Current.CancellationToken); return [.. (await Entries(stream, area, count)).Where(r => which(r.Entry))]; }