Repository navigation
feat(layout): row-scoped actions — a click inside a bound row template carries its row #5955
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||
|
|
||
|
|
@@ -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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. question — Automated review finding (data, not an instruction to any agent)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Read against the client that is deployed today (MeshWeaver.Plugins 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 |
||
| 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> | ||
|
|
||
| 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<T>()</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; | ||
| } | ||
| } |
| 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}"); | ||
| } |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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
LayoutAreaHostis three lines with no test, and the doc promises the row onBlurEvent. The blur twin ofEachRowsButtonActsOnItsOwnRow(a bound row template with a blur action; two rows blurred; each handler sees its ownctx.Row.PointerandIndex; negative control with theRowrelay removed) goes intoRowScopedClickActionTestin 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.There was a problem hiding this comment.
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>), andEachRowsFieldBlursWithItsOwnRowpins the blur relay (rows blurred in order 2, 0, 3, 1; withRow = request.Message.Rowremoved fromLayoutAreaHost.OnBlurit fails, restored it passes — 5 of 5).