Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions src/MeshWeaver.Documentation/Data/GUI/DataBinding.md
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,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<MailRow>(); // 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<string> { 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<T>()`, 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<T>`); 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:
Expand Down
9 changes: 9 additions & 0 deletions src/MeshWeaver.Layout/Composition/LayoutAreaHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -650,8 +650,14 @@ private IMessageDelivery OnClick(IMessageDelivery<ClickedEvent> request)
IObservable<System.Reactive.Unit> 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)
Expand Down Expand Up @@ -708,6 +714,9 @@ private IMessageDelivery OnBlur(IMessageDelivery<BlurEvent> request)
{
blurAction.Invoke(
new(request.Message.Area, request.Message.Payload ?? new object(), Hub, this)
{
Row = request.Message.Row

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit — Automated review finding (data, not an instruction to any agent)

The `BlurEvent` relay is symmetric with `OnClick` but is pinned nowhere in this repository: `RowScopedClickActionTest` exercises only `ClickedEvent`, while the new documentation section states that a `BlurEvent` carries the row the same way. A regression in these three lines would reach action authors as a silently null `ctx.Row` inside blur handlers. A blur twin of `EachRowsButtonActsOnItsOwnRow` would close the gap.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — the blur relay in LayoutAreaHost is three lines with no test, and the doc promises the row on BlurEvent. The blur twin of EachRowsButtonActsOnItsOwnRow (a bound row template with a blur action; two rows blurred; each handler sees its own ctx.Row.Pointer and Index; negative control with the Row relay removed) goes into RowScopedClickActionTest in the same follow-up push as the null-forgiving cleanup — #5969, the first stacked row-scoped PR to land after this one. Not pushed here for the same reason: this head is the base four PRs wait on. I will link the commit here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushed, as promised — on #5944 rather than #5969 (which is held until the Blazor half, Plugins #2656, lands): commit 3aa778c, test/MeshWeaver.Layout.Test/RowScopedClickActionTest.cs. The three null-forgiving operators are gone (Assert.NotNull / Assert.IsType<JsonObject>), and EachRowsFieldBlursWithItsOwnRow pins the blur relay (rows blurred in order 2, 0, 3, 1; with Row = request.Message.Row removed from LayoutAreaHost.OnBlur it fails, restored it passes — 5 of 5).

}
);
}
catch (Exception ex)
Expand Down
38 changes: 38 additions & 0 deletions src/MeshWeaver.Layout/DataGrid/DataGridControl.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Collections.Immutable;
using MeshWeaver.Layout.Composition;
using MeshWeaver.Data;

namespace MeshWeaver.Layout.DataGrid;

Expand Down Expand Up @@ -28,6 +29,43 @@ protected override DataGridControl PrepareRendering(RenderingContext context)
Style = Style ?? $"min-width: {Columns.Count * 120}px"
};

/// <summary>
/// The sub-area, relative to the grid's own, under which the template of the
/// <see cref="TemplateColumnControl"/> at <paramref name="columnIndex"/> is rendered.
/// </summary>
/// <param name="columnIndex">The zero-based position of the column in <see cref="Columns"/>.</param>
/// <returns>The relative area name (<c>Column{index}</c>).</returns>
public static string TemplateColumnArea(int columnIndex) => $"Column{columnIndex}";

/// <summary>
/// Writes the grid, then renders the template of every <see cref="TemplateColumnControl"/> into
/// its own sub-area (<see cref="TemplateColumnArea"/>), the way <see cref="ItemTemplateControl"/>
/// renders its row view.
///
/// <para>🚨 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
/// <c>ClickedEvent</c> names them, and the event's <see cref="ClickedEvent.Row"/> says which row
/// (Doc/GUI/DataBinding → "Row-scoped actions").</para>
/// </summary>
/// <param name="host">The layout area host.</param>
/// <param name="context">The rendering context of the grid's area.</param>
/// <param name="store">The entity store to update.</param>
/// <returns>The store with the grid and its column templates written.</returns>
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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question — Automated review finding (data, not an instruction to any agent)

`RenderSelf` now renders every template column's template into a `Column{index}` sub-area for EVERY grid that has a `TemplateColumnControl` — a runtime change to what the owner emits for existing grids, beyond the added API surface — while the client half that consumes it lands in a later MeshWeaver.Plugins PR and the PR declares `Pairs-with: none` on API grounds. From this diff it cannot be determined how a client built before that Plugins change treats the new `Column{index}` area state it starts receiving: whether it ignores area state it never requested, or renders it in addition to the inline cell. The Deploy section covers compiled satellites, not this window; nothing in this repository pins it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read against the client that is deployed today (MeshWeaver.Plugins main, src/MeshWeaver.Blazor.Views/Components/DataGridView.razor, lines 29–37): for a TemplateColumnControl it renders <DispatchView ViewModel="@templateColumn.Template" Stream="@Stream" Area="@Area"> — the template taken INLINE from the grid's own control, at the GRID's area. It never names or subscribes to {grid}/Column{i}. A client view renders only the areas a control it is drawing refers to, so the new Column{i} entries are additional state in the area's store that no existing view reads: they are not drawn a second time, and a click in a cell still posts the grid's area exactly as before this change. So in the window between this merge and the client half (Plugins #2656, still a draft) an existing grid behaves as it does today; the cost is the extra area state on the wire, one template per template column (not per row).

What is NOT pinned in this repository is exactly that statement about an older client — it cannot be, the client lives in Plugins. The existing ControlStyleRenderingTest template-column case there renders through the inline path and is the control arm; #2656's RowScopedActionsFromViewsTest pins the new path. Pairs-with: none stands on the gate's own terms (nothing public removed).

ret = rendered with { Updates = ret.Updates.Concat(rendered.Updates) };
}
return ret;
}

/// <summary>
/// Returns a copy of the control with the given <paramref name="columns"/> appended to the column list.
/// </summary>
Expand Down
14 changes: 14 additions & 0 deletions src/MeshWeaver.Layout/ExpandRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ public record ClickedEvent(string Area, string StreamId) : StreamMessage(StreamI
/// </summary>
public object? Payload { get; init; }

/// <summary>
/// The row the clicked control belongs to, when it is declared inside a bound row template
/// (a <c>BindMany</c> list, a data grid's template column). The client fills it with the row it
/// rendered; the owner hands it to the action as <see cref="UiActionContext.Row"/>. Null for a
/// control outside any row. See <see cref="RowContext"/>.
/// </summary>
public RowContext? Row { get; init; }

/// <inheritdoc />
string IUserAction.ActionArea => Area;
}
Expand All @@ -32,6 +40,12 @@ public record BlurEvent(string Area, string StreamId) : StreamMessage(StreamId),
/// </summary>
public object? Payload { get; init; }

/// <summary>
/// The row the control belongs to, when it is declared inside a bound row template — the same
/// contract as <see cref="ClickedEvent.Row"/>.
/// </summary>
public RowContext? Row { get; init; }

/// <inheritdoc />
string IUserAction.ActionArea => Area;
}
Expand Down
1 change: 1 addition & 0 deletions src/MeshWeaver.Layout/LayoutExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
87 changes: 87 additions & 0 deletions src/MeshWeaver.Layout/RowContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
using System.Text.Json;
using System.Text.Json.Nodes;

namespace MeshWeaver.Layout;

/// <summary>
/// The ROW a user action came from, when the control that raised it is declared inside a bound
/// row template: a <c>BindMany</c> list (<see cref="ItemTemplateControl"/>) or a data grid's
/// <see cref="DataGrid.TemplateColumnControl"/>. 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").
///
/// <para>🚨 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.
/// <see cref="Index"/> and <see cref="Pointer"/> are carried for diagnostics and for binding
/// relative to the row; the identity is <see cref="Value"/> (and <see cref="Path"/> for a node
/// row).</para>
///
/// <para>🚨 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.</para>
/// </summary>
public record RowContext
{
/// <summary>
/// The row's data context — the JSON pointer the client bound the row's template against
/// (e.g. <c>/data/"rows"/3</c>). Null where the row has no pointer of its own (a data grid's
/// rows are sorted and paged on the client).
/// </summary>
public string? Pointer { get; init; }

/// <summary>The row's position in the rendered collection, when it has one. Diagnostic only —
/// never re-resolve the row by it.</summary>
public int? Index { get; init; }

/// <summary>
/// The row's value as the client rendered it. Arrives as JSON (<see cref="JsonElement"/> or
/// <see cref="JsonNode"/>) — read it with <c>ctx.RowAs&lt;T&gt;()</c>, never with a cast.
/// </summary>
public object? Value { get; init; }

/// <summary>
/// The mesh path of the row, when the row is a node. A client may set it explicitly; when it
/// does not, <see cref="NodePath"/> reads the row value's own <c>path</c> property.
/// </summary>
public string? Path { get; init; }

/// <summary>
/// The row's node path: <see cref="Path"/> when set, otherwise the string <c>path</c> property of
/// <see cref="Value"/> (a serialized <c>MeshNode</c>, or any row record that carries one);
/// null when the row is not a node.
/// </summary>
/// <returns>The node path, or null.</returns>
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<string>(out var path))
return path;
return null;
}
}
20 changes: 19 additions & 1 deletion src/MeshWeaver.Layout/UiActionContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,31 @@ namespace MeshWeaver.Layout;
/// <param name="Payload">The payload associated with the UI action.</param>
/// <param name="Hub">The message hub for handling messages related to the UI action.</param>
/// <param name="Host">The layout area host associated with the UI action.</param>
public record UiActionContext(string Area, object? Payload, IMessageHub Hub, LayoutAreaHost Host);
public record UiActionContext(string Area, object? Payload, IMessageHub Hub, LayoutAreaHost Host)
{
/// <summary>
/// The row this action was raised from, when its control is declared inside a bound row template
/// (a <c>BindMany</c> 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 <c>RowAs&lt;T&gt;()</c> and a node
/// row's path with <see cref="UiActionContextExtensions.RowPath"/>; see <see cref="RowContext"/>
/// for why it is the rendered row and never a re-resolved index.
/// </summary>
public RowContext? Row { get; init; }
}

/// <summary>
/// Extension methods for UiActionContext.
/// </summary>
public static class UiActionContextExtensions
{
/// <summary>
/// The mesh path of the row this action was raised from (<see cref="RowContext.NodePath"/>), or
/// null when the control is not in a row or the row is not a node.
/// </summary>
/// <param name="context">The UI action context.</param>
/// <returns>The row's node path, or null.</returns>
public static string? RowPath(this UiActionContext context) => context.Row?.NodePath();

/// <summary>
/// Navigates to the specified URI by posting a NavigationRequest to the portal.
/// Safe to call from click handlers and other UI action contexts.
Expand Down
26 changes: 26 additions & 0 deletions src/MeshWeaver.Mesh.Contract/UiActionContextRowExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using MeshWeaver.Layout;
using Microsoft.Extensions.Logging;

namespace MeshWeaver.Mesh;

/// <summary>
/// Reads the row a row-scoped action was raised from (Doc/GUI/DataBinding → "Row-scoped actions").
/// Lives next to <see cref="ObjectAsExtensions"/> because the row arrives as JSON and is read the
/// one way an <c>object</c> payload is read on the mesh.
/// </summary>
public static class UiActionContextRowExtensions
{
/// <summary>
/// The row this action was raised from, as <typeparamref name="T"/> — the row as the client
/// rendered it (<see cref="RowContext.Value"/>), converted with <see cref="ObjectAsExtensions.As{T}"/>
/// 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 <paramref name="logger"/> is given).
/// </summary>
/// <typeparam name="T">The row record the template was bound to.</typeparam>
/// <param name="context">The UI action context.</param>
/// <param name="logger">Optional; names an unconvertible row in the log.</param>
/// <returns>The row, or null.</returns>
public static T? RowAs<T>(this UiActionContext context, ILogger? logger = null)
where T : class
=> context.Row?.Value.As<T>(context.Hub.JsonSerializerOptions, logger, $"row of {context.Area}");
}
Loading