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
15 changes: 15 additions & 0 deletions src/MeshWeaver.Documentation/Data/GUI/DataBinding.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,21 @@ The in-mesh samples are converted, so copy them rather than the framework areas

Each sample's `Test/` folder asserts its template on the mesh: the template is built from a PATH (and, for a fed control, an `Observable.Never` feed — the template must be whole while its feed is silent), and `LayoutTemplate.DeferredViews(template)` must be empty — `LayoutTemplate` (`MeshWeaver.Layout`) is the platform's reading of a control tree, public so in-mesh C# can use it. The cases also pin which pointers are bound against which context. A feed's own subscription is opened by `Template.Bind`'s build-up, so it belongs to the rendered area and ends with it; a feed reports a failure as text and a log line, never by going quiet.

### A decision over several fields: publish a projection, bind the template to it

Some of what a page shows is not a FIELD of a node but a DECISION over several — a compile panel whose chip, colour and button label depend on status × build presence × dirty flag, in the viewer's language. A field binding cannot express that, and computing it in a `GetMeshNodeStream().Select(…)` that builds controls is the bake. The shape the NodeType pages use:

1. A **pure record** of the decided values (`NodeTypeStatusView` — title, status lines, panel style, button label, links), made by a pure `From(node, definition, path, locale)` that tests pin without a renderer.
2. A **projection** — the ONE read of the node — `GetMeshNodeStream().Select(NodeTypeStatusView.From…)`, a function that builds no control.
3. A **template** whose controls carry `DataContext = LayoutAreaReference.GetDataPointer(id)` and pointers into the record (`Controls.Body(pointer)`, `Style = pointer`, a button's `Disabled = pointer`), with the projection attached by `control.PublishingTo(id, projection)` (`MeshWeaver.Graph.LayoutProjection`): a buildup that publishes to `/data/{id}` for as long as the AREA lives and is disposed with it.

The template is emitted at once and the values fill in; nothing is ever interpolated into a control. Lists go further and leave the hub entirely: the NodeType release history, the Settings Groups tab and the Admin Data Sources tab are `Controls.MeshSearch.WithHiddenQuery(…)`, run by the viewer's client.

Two things a template cannot close from the server side — both live in the Blazor layer:

- **A code editor reads a node field only through `CodeEditorControl.BindToNode`** ("Binding a rich control to a node field" below), whose view half ships with the Blazor client; a `Value` pointer handed to the layout stream is never resolved against a node-bound DataContext. The NodeType configuration preview does not need it: its text is a DECISION (the lambda, or a localized placeholder when there is none), so it binds to a `/data` projection like the rest of the page.
- **`NodeExportView` reads `NodeName` / `AvailableSatelliteTypes` straight off the view model**, so the Export area still loads them on the hub; the conversion is the view resolving both from `SourcePath` itself.

### The ratchet

`test/MeshWeaver.Documentation.Test/LayoutAreaDataBakeRatchetGuard` counts, per file under `src/`, `memex/` and `samples/`, the layout-area units (methods, local functions, lambdas taking a `LayoutAreaHost`) that both READ data and BUILD controls. The seeded inventory is `test/LayoutAreaDataBakeSites.allow`; it may only shrink. Converting an area means lowering its line (and `TotalBudget`) in the same change. It is a text heuristic, and it says so: a load reached through another file's helper is missed, and an area that reads data only to choose its STRUCTURE (a permission gate) is counted — so the file is an inventory to work down, not a verdict on every line.
Expand Down
18 changes: 14 additions & 4 deletions src/MeshWeaver.Graph/CreateLayoutArea.cs
Original file line number Diff line number Diff line change
Expand Up @@ -140,18 +140,28 @@ private static void ShowErrorDialog(UiActionContext ctx, string title, string me
/// Renders an icon value as a 48x48 preview. Supports three forms:
/// inline SVG markup, an http(s) or /api/content URL, and a FluentIcon name.
/// </summary>
internal static UiControl BuildIconPreview(string icon)
internal static UiControl BuildIconPreview(string icon) => Controls.Html(IconPreviewMarkup(icon));

/// <summary>
/// The markup of the 48px icon preview for <paramref name="icon"/> — an inline SVG plated and
/// sized, an image URL, or the raw value as text; a dashed empty box for no icon. Shared by the
/// Create form and the Settings icon picker, which binds to it as data.
/// </summary>
/// <param name="icon">The node's Icon value, or empty.</param>
internal static string IconPreviewMarkup(string? icon)
{
if (string.IsNullOrEmpty(icon))
return "<div style=\"width:48px;height:48px;border:1px dashed var(--neutral-stroke-rest);border-radius:6px;\"></div>";
const string boxStyle = "width:48px;height:48px;display:flex;align-items:center;justify-content:center;border:1px solid var(--neutral-stroke-rest);border-radius:6px;color:var(--neutral-foreground-rest);";
// Through MeshNodeImageHelper.SizeInlineSvg, which plates the icon (#4350) and sizes it into
// the 48px box — the preview has to show what the portal will actually draw, and drawn raw
// a currentColor outline is invisible on one of the two themes.
if (icon.TrimStart().StartsWith("<svg", StringComparison.OrdinalIgnoreCase))
return Controls.Html($"<div style=\"{boxStyle}\">{MeshNodeImageHelper.SizeInlineSvg(icon, 32)}</div>");
return $"<div style=\"{boxStyle}\">{MeshNodeImageHelper.SizeInlineSvg(icon, 32)}</div>";
if (icon.StartsWith("http", StringComparison.OrdinalIgnoreCase) || icon.StartsWith("/")
|| icon.StartsWith("data:image", StringComparison.OrdinalIgnoreCase))
return Controls.Html($"<div style=\"{boxStyle}\"><img src=\"{System.Web.HttpUtility.HtmlAttributeEncode(icon)}\" style=\"max-width:32px;max-height:32px;\" /></div>");
return Controls.Html($"<div style=\"{boxStyle}\"><span style=\"font-size:12px;\">{System.Web.HttpUtility.HtmlEncode(icon)}</span></div>");
return $"<div style=\"{boxStyle}\"><img src=\"{System.Web.HttpUtility.HtmlAttributeEncode(icon)}\" style=\"max-width:32px;max-height:32px;\" /></div>";
return $"<div style=\"{boxStyle}\"><span style=\"font-size:12px;\">{System.Web.HttpUtility.HtmlEncode(icon)}</span></div>";
}

/// <summary>
Expand Down
135 changes: 22 additions & 113 deletions src/MeshWeaver.Graph/GlobalSettingsLayoutArea.cs
Original file line number Diff line number Diff line change
Expand Up @@ -177,122 +177,31 @@ private static UiControl BuildContentPane(
{ LabelKey = "settings.dataSources", GroupKey = AdminAppNodeType.OperationsGroupKey };

/// <summary>
/// Data Sources tab: lists all MeshDataSource nodes with status and actions.
/// Data Sources tab: the registered MeshDataSource nodes, listed by the GUI
/// (<see cref="DataSourcesList"/>) — a template, so the tab renders at once and the list fills in.
/// </summary>
internal static UiControl BuildDataSourcesTab(LayoutAreaHost host, StackControl stack)
{
stack = stack.WithView(Controls.H2(host.Localize("settings.dataSources")).WithStyle("margin: 0 0 8px 0;"));
stack = stack.WithView(Controls.Markdown(host.Localize("settings.dataSourcesIntro")));

var meshService = host.Hub.ServiceProvider.GetService<IMeshService>();
if (meshService == null)
{
stack = stack.WithView(Controls.Html(
"<p style=\"color: var(--neutral-foreground-hint);\">Mesh service not available.</p>"));
return stack;
}

stack = stack.WithView((h, _) =>
meshService
.Query<MeshNode>(MeshQueryRequest.FromQuery(
$"namespace:{MeshDataSourceNodeType.SourcesNamespace} nodeType:{MeshDataSourceNodeType.NodeType}"))
.Select(change =>
{
var nodes = change.Items?.ToList() ?? [];
if (nodes.Count == 0)
return (UiControl?)Controls.Html(
"<p style=\"color: var(--neutral-foreground-hint);\">No data sources registered.</p>");

return (UiControl?)BuildDataSourcesList(nodes, host.Hub.JsonSerializerOptions, locale: host.ViewerLocale());
}));

return stack;
}

private static UiControl BuildDataSourcesList(List<MeshNode> nodes, System.Text.Json.JsonSerializerOptions options, string? locale = null)
{
var container = Controls.Stack.WithWidth("100%").WithStyle("gap: 12px;");

foreach (var node in nodes.OrderBy(n => n.Name))
{
var config = node.ContentAs<MeshDataSourceConfiguration>(options);
var isEnabled = config?.Enabled ?? true;
var isSearchable = config?.IncludeInSearch ?? true;
var isInstalled = !string.IsNullOrEmpty(config?.InstalledTo);

var card = Controls.Stack.WithWidth("100%")
.WithStyle("padding: 16px; border: 1px solid var(--neutral-stroke-rest); border-radius: 8px; gap: 8px;");

// Header row: name + provider badge + status
var header = Controls.Stack.WithOrientation(Orientation.Horizontal)
.WithStyle("align-items: center; gap: 8px;");
header = header.WithView(Controls.Html(
$"<div style=\"font-size: 1.05rem; font-weight: 600;\">{Esc(node.Name ?? node.Path)}</div>"));

if (config?.ProviderType != null)
{
header = header.WithView(Controls.Html(
$"<span style=\"font-size: 0.75rem; padding: 2px 8px; background: var(--neutral-layer-2); border-radius: 4px;\">{Esc(config.ProviderType)}</span>"));
}

var statusColor = isEnabled ? "#4ade80" : "#f87171";
var statusText = isEnabled ? "Enabled" : "Disabled";
header = header.WithView(Controls.Html(
$"<span style=\"margin-left: auto; font-size: 0.8rem; font-weight: 600; color: {statusColor};\">{statusText}</span>"));

card = card.WithView(header);
=> stack
.WithView(Controls.H2(host.Localize("settings.dataSources")).WithStyle("margin: 0 0 8px 0;"))
.WithView(Controls.Markdown(host.Localize("settings.dataSourcesIntro")))
.WithView(DataSourcesList());

// Description
if (!string.IsNullOrEmpty(config?.Description))
{
card = card.WithView(Controls.Html(
$"<p style=\"font-size: 0.85rem; color: var(--neutral-foreground-hint); margin: 0;\">{Esc(config.Description)}</p>"));
}

// Installed note
if (isInstalled)
{
card = card.WithView(Controls.Html(
$"<div style=\"padding: 6px 10px; background: var(--warning-fill-rest); border-radius: 6px; font-size: 0.8rem;\">" +
$"Installed to <strong>{Esc(config!.InstalledTo!)}</strong>" +
(config.LastSyncedAt.HasValue ? $" — last synced: {config.LastSyncedAt.Value:yyyy-MM-dd HH:mm}" : "") +
"</div>"));
}

// Info row: search status + storage path
var info = Controls.Stack.WithOrientation(Orientation.Horizontal)
.WithStyle("gap: 16px; font-size: 0.8rem; color: var(--neutral-foreground-hint);");
info = info.WithView(Controls.Html(
$"<span>Search: <strong>{(isSearchable ? "Included" : "Excluded")}</strong></span>"));
if (config?.StorageConfig?.BasePath != null)
{
info = info.WithView(Controls.Html(
$"<span>Path: <code>{Esc(config.StorageConfig.BasePath)}</code></span>"));
}
card = card.WithView(info);

// Action buttons
var buttonRow = Controls.Stack.WithOrientation(Orientation.Horizontal)
.WithStyle("gap: 8px; margin-top: 4px;");

buttonRow = buttonRow.WithView(Controls.Button(LocalizationCatalog.Get("ui.open", locale))
.WithAppearance(Appearance.Accent)
.WithClickAction(ctx =>
{
var navService = ctx.Host.Hub.ServiceProvider.GetService<INavigationService>();
navService?.NavigateTo($"/{node.Path}");
return Task.CompletedTask;
}));

card = card.WithView(buttonRow);

container = container.WithView(card);
}

return container;
}
/// <summary>
/// The registered data sources as a query control: the viewer's client runs the query and
/// renders each source as a card that opens it, and keeps the list live. It used to be loaded
/// here and drawn as hand-built HTML cards, so the tab waited on the query and showed a
/// snapshot (Doc/GUI/DataBinding → "Templates first, data later").
/// </summary>
internal static MeshSearchControl DataSourcesList()
=> Controls.MeshSearch
.WithHiddenQuery(
$"namespace:{MeshDataSourceNodeType.SourcesNamespace} nodeType:{MeshDataSourceNodeType.NodeType} sort:name")
.WithShowSearchBox(false)
.WithShowEmptyMessage(true)
.WithRenderMode(MeshSearchRenderMode.List)
.WithCollapsibleSections(false)
.WithSectionCounts(false)
.WithReactiveMode(true);

#endregion

private static string Esc(string s) => System.Web.HttpUtility.HtmlEncode(s);
}
46 changes: 46 additions & 0 deletions src/MeshWeaver.Graph/LayoutProjection.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using System.Reactive.Linq;
using MeshWeaver.Layout;
using MeshWeaver.Layout.Composition;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace MeshWeaver.Graph;

/// <summary>
/// "Rows computed on the hub" from Doc/GUI/DataBinding → "Templates first, data later": a TEMPLATE
/// is emitted at once with its controls bound by pointer to <c>/data/{id}</c>, and a PROJECTION —
/// values the hub has to decide (a status line, a localized sentence) — is published there for as
/// long as the area lives. The control never waits for the projection, and never carries its values.
///
/// <para>This is <see cref="Template.Bind{T,TView}(IObservable{T},System.Linq.Expressions.Expression{Func{T,TView}}?,string?)"/>
/// without the expression rewriting: the templates here state their pointers explicitly, so a reader
/// sees exactly which field each control reads.</para>
/// </summary>
internal static class LayoutProjection
{
/// <summary>
/// Returns <paramref name="control"/> with a buildup that publishes <paramref name="projection"/>
/// at <c>/data/{<paramref name="id"/>}</c> while the area it is rendered into lives. A fault is
/// logged and ends the projection — the bound controls keep their last values.
/// </summary>
/// <typeparam name="TControl">The control type.</typeparam>
/// <typeparam name="T">The projection's type.</typeparam>
/// <param name="control">The template root.</param>
/// <param name="id">The <c>/data</c> id the template's controls point into.</param>
/// <param name="projection">The values, decided on the hub.</param>
public static TControl PublishingTo<TControl, T>(this TControl control, string id, IObservable<T> projection)
where TControl : UiControl
=> (TControl)control.WithBuildup((host, context, store) =>
{
var subscription = projection
.DistinctUntilChanged()
.Subscribe(
value => host.Stream.SetData(id, value, host.Stream.StreamId),
ex => host.Hub.ServiceProvider.GetRequiredService<ILoggerFactory>()
.CreateLogger(typeof(LayoutProjection))
.LogWarning(ex, "Projection {Id} for area {Area} faulted; its bound controls keep their last values",
id, context.Area));
host.RegisterForDisposal(context.Area, subscription);
return new(store, [], null);
});
}
Loading