diff --git a/src/MeshWeaver.Documentation/Data/GUI/DataBinding.md b/src/MeshWeaver.Documentation/Data/GUI/DataBinding.md
index ae6d6d0345..af09c9a536 100644
--- a/src/MeshWeaver.Documentation/Data/GUI/DataBinding.md
+++ b/src/MeshWeaver.Documentation/Data/GUI/DataBinding.md
@@ -127,7 +127,7 @@ These areas of every node hub are templates; each pins its shape in `test/MeshWe
|---|---|
| `Thumbnail` (default and Markdown) | `MeshNodeThumbnailControl.ForPath(hubPath)` — the card's view binds title and image from the node. |
| `NodeTypes` | Own type from `NodeTypePathHolder` as a card by path; the types at this level as a `MeshSearch` the GUI runs. It used to take a one-shot query snapshot that never showed a type added later. |
-| `Search` | The ordinary catalog is emitted at once; whether to show a NodeType's instance catalog is read from configuration. Only a NodeType DEFINITION's catalog still reads its node, because its query is built from the definition's `DefaultNamespace`. |
+| `Search` | The ordinary catalog is emitted at once; whether to show a NodeType's instance catalog is read from configuration. A NodeType DEFINITION's catalog is emitted at once too: the breadcrumb trail plus the `NodeTypeInstances` slot with a skeleton, carrying the page's query string (`?groupBy`, `?q`, …). Only that slot reads the node, because the instance list's query and create link are built from the definition's `DefaultNamespace` and `RestrictedToNamespaces` (`NodeTypeCatalogQuery.From`, pure). It is a STRUCTURE read like `NodeContentForm` below: the list itself is still a `MeshSearch` the GUI runs, and the slot re-renders only when the query or the create link changes, so an edit of the definition reaches the open page. Binding `MeshSearch.HiddenQuery` by pointer would remove the slot; the views resolve it as a literal string today. The ratchet's text scan does not count the slot, because its controls are built by pure helpers (`BuildNodeTypeSearch`, `BuildCatalog`); unlike `NodeContentForm`, it has left the inventory, and it is a structure read all the same. |
| `Notebook` (Markdown) | Header with the name bound by pointer; the cells — parsed from the markdown — render in the `NotebookCells` slot with a skeleton. |
| `$Schema` (self) | The content type the hub's `MeshDataSource` was configured with; no read. |
| `$Data`, `$Content` (self) | A markdown/HTML control bound to a projection of the node, following later edits. On a node hub the layout's `DataPathViews` renderer also matches `$Data` and, running after the named renderer, overwrites it — a client sees that one there. |
diff --git a/src/MeshWeaver.Graph/MeshNodeLayoutAreas.cs b/src/MeshWeaver.Graph/MeshNodeLayoutAreas.cs
index 7d75c684c5..7bbb125d76 100644
--- a/src/MeshWeaver.Graph/MeshNodeLayoutAreas.cs
+++ b/src/MeshWeaver.Graph/MeshNodeLayoutAreas.cs
@@ -130,6 +130,12 @@ public static UiControl BuildInlineCommentsSection(LayoutAreaHost host)
.WithView(Controls.LayoutArea(host.Hub.Address, CommentsArea).WithShowProgress(false));
/// Area name for the node Search layout area.
public const string SearchArea = "Search";
+ ///
+ /// Area name for the slot a NodeType definition's renders its instance
+ /// list in () — the one part of that page computed from the
+ /// definition; the page around it renders at once.
+ ///
+ public const string NodeTypeInstancesArea = "NodeTypeInstances";
/// Area name for the node Files layout area.
public const string FilesArea = "Files";
/// Area name for the NodeTypes layout area.
@@ -248,6 +254,7 @@ public static LayoutDefinition AddDefaultLayoutAreas(this LayoutDefinition layou
.WithView(ThumbnailArea, Thumbnail)
.WithView(SettingsArea, SettingsLayoutArea.Settings)
.WithView(SearchArea, Search)
+ .WithView(NodeTypeInstancesArea, NodeTypeInstances)
.WithView(FilesArea, Files)
.WithView(ThreadsArea, Threads)
.WithView(ChatArea, Chat)
@@ -1468,12 +1475,13 @@ private static string GetNodeContent(MeshNode? node)
// node (Doc/GUI/DataBinding → "Templates first, data later"). NodeType catalog mode is
// used when either (a) the hub opts in via NodeTypeCatalogMode (e.g. AddNodeTypeView), or
// (b) the node is a NodeType DEFINITION — this hub then applies the "NodeType" type's own
- // configuration, which the activation funnel records as NodeTypePathHolder. Every
- // ordinary node's catalog — the hot path — is therefore a template emitted at once; only
- // a definition's catalog reads its node, because its query is built from the definition.
+ // configuration, which the activation funnel records as NodeTypePathHolder. Both branches
+ // are templates emitted at once: a definition's instance list — whose query is computed
+ // from the definition — renders into its own skeleton slot (NodeTypeInstances).
if (!IsNodeTypeCatalog(host.Hub.Configuration))
return Observable.Return(InstanceCatalog(host, hubPath));
- return NodeTypeCatalog(host, hubPath);
+ return Observable.Return(
+ NodeTypeCatalogTemplate(host.Hub.Address, hubPath, host.Reference.Id?.ToString()));
}
///
@@ -1498,78 +1506,113 @@ private static UiControl InstanceCatalog(LayoutAreaHost host, string hubPath)
hubPath);
///
- /// A NodeType definition's catalog of its instances. Its hidden query and create link are built
- /// from the DEFINITION (,
- /// ), which only the node carries — the
- /// one branch of that reads data to decide its structure.
+ /// A NodeType definition's catalog page — a TEMPLATE that reads nothing: the breadcrumb trail
+ /// and a slot with the skeleton loading shape. The slot
+ /// carries the page's own query string (?groupBy, ?q, …), so every catalog knob
+ /// still reaches the list.
///
- private static IObservable NodeTypeCatalog(LayoutAreaHost host, string hubPath)
- {
- return host.Workspace.GetMeshNodeStream().Select(node =>
- {
- // For NodeType mode, query instances under this NodeType's namespace.
- // Uses the node's own path as namespace to correctly scope to local instances.
- // E.g., FutuRe/EuropeRe/LineOfBusiness → finds children under that namespace,
- // regardless of whether they reference the local or parent nodeType path.
- if (node != null)
- {
- var nodeTypePath = node.Path;
- var nodeTypeDefinition = node.ContentAs(host.Hub.JsonSerializerOptions);
-
- // Build query. If DefaultNamespace is set, scope to that namespace
- // and filter by this NodeType (canonical group case — instances
- // declare nodeType = path).
- //
- // Otherwise scope by namespace + descendants. The nodeType filter is
- // dropped here because LOCAL NodeType nodes (e.g.
- // FutuRe/EuropeRe/LineOfBusiness inside the FutuRe/LineOfBusiness root
- // type) reuse the GROUP-level nodeType on their instances — filtering
- // by the local NodeType node's own path matches zero instances.
- //
- // `is:main` drops satellites that carry an explicit MainNode pointer —
- // _Activity compile-activity nodes (NodeType="Activity", MainNode=)
- // are the case the old `-nodeType:Code` enumeration missed when
- // compile-activity landed, surfacing a "Compile {path}" row. It does NOT
- // catch Source/Code files: the file-system loader leaves their MainNode
- // null, so they read as main nodes — hence `-nodeType:Code` stays.
- // `-nodeType:NodeType -nodeType:Markdown` also stay: definition nodes
- // are main nodes too.
- var hiddenQuery = nodeTypeDefinition?.DefaultNamespace != null
- ? $"nodeType:{nodeTypePath} namespace:{nodeTypeDefinition.DefaultNamespace}"
- : $"namespace:{nodeTypePath} scope:subtree is:main -nodeType:Code -nodeType:NodeType -nodeType:Markdown";
- var defaultNs = nodeTypeDefinition?.DefaultNamespace;
- var createNs = !string.IsNullOrEmpty(defaultNs) ? defaultNs : hubPath;
-
- var createQs = $"type={Uri.EscapeDataString(nodeTypePath)}";
- if (!string.IsNullOrEmpty(defaultNs))
- createQs += $"&namespace={Uri.EscapeDataString(defaultNs)}";
- if (nodeTypeDefinition?.RestrictedToNamespaces is { Count: > 0 } nsRestrictions)
- createQs += $"&namespaces={string.Join(",", nsRestrictions.Select(Uri.EscapeDataString))}";
-
- var createHref = $"/create?{createQs}";
+ /// The node hub's address — the slot renders on the same hub.
+ /// The definition's path (the breadcrumb trail).
+ /// The Search area's reference id: its query string, forwarded.
+ internal static UiControl NodeTypeCatalogTemplate(object address, string hubPath, string? queryString)
+ => WithBreadcrumbs(
+ Controls.LayoutArea(address, NodeTypeInstancesArea, queryString)
+ .WithSpinnerType(SpinnerType.Skeleton),
+ hubPath);
+ ///
+ /// The slot: a NodeType definition's list of its instances.
+ /// Its hidden query and create link are built from the DEFINITION
+ /// (,
+ /// ), which only the node carries — a
+ /// STRUCTURE read, deferred to this slot alone (Doc/GUI/DataBinding → the toolkit's last row).
+ /// The list itself is still run by the GUI, and the slot re-renders only when the query or the
+ /// create link changes.
+ ///
+ /// The layout area host.
+ /// The rendering context (unused).
+ /// The instance list, or the ordinary catalog while no definition is readable.
+ [Browsable(false)]
+ public static IObservable NodeTypeInstances(LayoutAreaHost host, RenderingContext _)
+ {
+ var hubPath = host.Hub.Address.ToString();
+ var options = host.Hub.JsonSerializerOptions;
+ return host.Workspace.GetMeshNodeStream()
+ .Select(node => node is null
+ ? null
+ : NodeTypeCatalogQuery.From(node.Path, node.ContentAs(options)))
+ .DistinctUntilChanged()
+ .Select(query => (UiControl?)(query is null
+ // No definition to read (yet): the ordinary catalog, as before.
+ ? BuildCatalog(hubPath, ReadCatalogOptions(host, MeshSearchRenderMode.GraphNavigator))
// Instances of a NodeType default to a hierarchical list; every knob is still
// ?param-overridable (?groupBy ?searchBar ?maxColumns ?emptyMessage ?title …).
- var typeOpts = ReadCatalogOptions(host, MeshSearchRenderMode.Hierarchical);
- var typeSearch = Controls.MeshSearch
- .WithHiddenQuery(hiddenQuery)
- .WithVisibleQuery(typeOpts.SearchTerm ?? "")
- .WithNamespace(hubPath)
- .WithPlaceholder(typeOpts.Placeholder)
- .WithRenderMode(typeOpts.Mode)
- .WithShowSearchBox(typeOpts.ShowSearchBox)
- .WithShowEmptyMessage(typeOpts.ShowEmptyMessage)
- .WithMaxColumns(typeOpts.MaxColumns)
- .WithCreateHref(createHref);
- if (!string.IsNullOrEmpty(typeOpts.GroupByProperty))
- typeSearch = typeSearch.WithGroupBy(typeOpts.GroupByProperty);
- // Prepend the breadcrumb trail (ancestors → default pages, current node bold).
- return (UiControl?)WithBreadcrumbs(typeSearch, hubPath);
- }
+ : BuildNodeTypeSearch(hubPath, ReadCatalogOptions(host, MeshSearchRenderMode.Hierarchical), query)));
+ }
- // No definition to read (yet): the ordinary catalog, as before.
- return InstanceCatalog(host, hubPath);
- });
+ ///
+ /// What a NodeType definition decides about its instance list: the hidden query and the create
+ /// link. Pure — is the whole decision.
+ ///
+ /// The query listing the type's instances.
+ /// The create link, pre-filled with the type and its namespaces.
+ internal sealed record NodeTypeCatalogQuery(string HiddenQuery, string CreateHref)
+ {
+ /// The query and create link for the definition at .
+ /// The definition node's path.
+ /// The definition, or null when the content is not one.
+ /// The decision.
+ public static NodeTypeCatalogQuery From(string nodeTypePath, NodeTypeDefinition? definition)
+ {
+ // If DefaultNamespace is set, scope to that namespace and filter by this NodeType
+ // (canonical group case — instances declare nodeType = path).
+ //
+ // Otherwise scope by namespace + descendants. The nodeType filter is dropped here
+ // because LOCAL NodeType nodes (e.g. FutuRe/EuropeRe/LineOfBusiness inside the
+ // FutuRe/LineOfBusiness root type) reuse the GROUP-level nodeType on their instances —
+ // filtering by the local NodeType node's own path matches zero instances.
+ //
+ // `is:main` drops satellites that carry an explicit MainNode pointer — _Activity
+ // compile-activity nodes (NodeType="Activity", MainNode=) are the case the old
+ // `-nodeType:Code` enumeration missed when compile-activity landed, surfacing a
+ // "Compile {path}" row. It does NOT catch Source/Code files: the file-system loader
+ // leaves their MainNode null, so they read as main nodes — hence `-nodeType:Code`
+ // stays. `-nodeType:NodeType -nodeType:Markdown` also stay: definition nodes are main
+ // nodes too.
+ var defaultNs = definition?.DefaultNamespace;
+ var hiddenQuery = defaultNs != null
+ ? $"nodeType:{nodeTypePath} namespace:{defaultNs}"
+ : $"namespace:{nodeTypePath} scope:subtree is:main -nodeType:Code -nodeType:NodeType -nodeType:Markdown";
+
+ var createQs = $"type={Uri.EscapeDataString(nodeTypePath)}";
+ if (!string.IsNullOrEmpty(defaultNs))
+ createQs += $"&namespace={Uri.EscapeDataString(defaultNs)}";
+ if (definition?.RestrictedToNamespaces is { Count: > 0 } nsRestrictions)
+ createQs += $"&namespaces={string.Join(",", nsRestrictions.Select(Uri.EscapeDataString))}";
+ return new NodeTypeCatalogQuery(hiddenQuery, $"/create?{createQs}");
+ }
+ }
+
+ /// The instance list of a NodeType — a query the GUI runs. Pure.
+ /// The definition's hub path (the search's namespace).
+ /// The catalog knobs read from the page's query string.
+ /// The definition's query and create link.
+ /// The search control.
+ internal static MeshSearchControl BuildNodeTypeSearch(string hubPath, CatalogOptions typeOpts, NodeTypeCatalogQuery query)
+ {
+ var typeSearch = Controls.MeshSearch
+ .WithHiddenQuery(query.HiddenQuery)
+ .WithVisibleQuery(typeOpts.SearchTerm ?? "")
+ .WithNamespace(hubPath)
+ .WithPlaceholder(typeOpts.Placeholder)
+ .WithRenderMode(typeOpts.Mode)
+ .WithShowSearchBox(typeOpts.ShowSearchBox)
+ .WithShowEmptyMessage(typeOpts.ShowEmptyMessage)
+ .WithMaxColumns(typeOpts.MaxColumns)
+ .WithCreateHref(query.CreateHref);
+ if (!string.IsNullOrEmpty(typeOpts.GroupByProperty))
+ typeSearch = typeSearch.WithGroupBy(typeOpts.GroupByProperty);
+ return typeSearch;
}
/// Best-effort truthy parse for boolean query params (true/1/yes/on).
diff --git a/test/LayoutAreaDataBakeSites.allow b/test/LayoutAreaDataBakeSites.allow
index f63ad62759..0f11bdc9d9 100644
--- a/test/LayoutAreaDataBakeSites.allow
+++ b/test/LayoutAreaDataBakeSites.allow
@@ -15,7 +15,6 @@ samples/Graph/Data/ACME/Project/Source/ProjectLayoutAreas.cs 5
src/MeshWeaver.GitSync/GitHubSyncSettingsTab.cs 1
src/MeshWeaver.Graph/ExportLayoutArea.cs 1
src/MeshWeaver.Graph/MarkdownNotebookLayoutArea.cs 1
-src/MeshWeaver.Graph/MeshNodeLayoutAreas.cs 1
src/MeshWeaver.Graph/NodeTypeLayoutAreas.cs 5
src/MeshWeaver.Graph/OverviewLayoutArea.cs 1
src/MeshWeaver.Graph/SettingsLayoutArea.cs 2
diff --git a/test/MeshWeaver.Documentation.Test/LayoutAreaDataBakeRatchetGuard.cs b/test/MeshWeaver.Documentation.Test/LayoutAreaDataBakeRatchetGuard.cs
index fc68045c62..10b60d5328 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 = 51;
+ private const int TotalBudget = 34;
/// Reads that make a unit wait on data.
internal static readonly Regex LoadPattern = new(
diff --git a/test/MeshWeaver.Graph.Test/NodePageAreasAreTemplatesTest.cs b/test/MeshWeaver.Graph.Test/NodePageAreasAreTemplatesTest.cs
index c1c58d49f0..1c841bf55c 100644
--- a/test/MeshWeaver.Graph.Test/NodePageAreasAreTemplatesTest.cs
+++ b/test/MeshWeaver.Graph.Test/NodePageAreasAreTemplatesTest.cs
@@ -220,13 +220,68 @@ await bound.Should().Within(TestTimeouts.Convergence).Match(v => Text(v) == "Ren
}
[Fact]
- public async Task SearchOnADefinition_StillCatalogsItsInstances()
+ public void TheNodeTypeCatalogPage_IsATemplate_TheListInASkeletonSlot()
+ {
+ var page = MeshNodeLayoutAreas.NodeTypeCatalogTemplate(new Address(TypePath), TypePath, "?groupBy=flat&q=abc");
+
+ EveryViewIsStatic(page);
+ Descendants(page).OfType().Should().BeEmpty(
+ "the instance list's query is computed from the definition — it never sits in the frame");
+ var slot = Descendants(page).OfType().Should().ContainSingle(
+ "the one computed part renders into its own slot").Subject;
+ slot.Reference.Area.Should().Be(MeshNodeLayoutAreas.NodeTypeInstancesArea);
+ slot.Reference.Id.Should().Be("?groupBy=flat&q=abc", "every catalog knob still reaches the list");
+ slot.SpinnerType.Should().Be(SpinnerType.Skeleton, "the slot shows the loading shape, not a spinner");
+ }
+
+ [Fact]
+ public void TheNodeTypeCatalogQuery_IsDecidedByTheDefinition()
+ {
+ var plain = MeshNodeLayoutAreas.NodeTypeCatalogQuery.From(TypePath, new NodeTypeDefinition());
+ plain.HiddenQuery.Should().Be(
+ $"namespace:{TypePath} scope:subtree is:main -nodeType:Code -nodeType:NodeType -nodeType:Markdown");
+ plain.CreateHref.Should().Be($"/create?type={Uri.EscapeDataString(TypePath)}");
+
+ var grouped = MeshNodeLayoutAreas.NodeTypeCatalogQuery.From(TypePath, new NodeTypeDefinition
+ {
+ DefaultNamespace = "Tpl/Widgets",
+ RestrictedToNamespaces = ["Tpl/A", "Tpl/B"],
+ });
+ grouped.HiddenQuery.Should().Be($"nodeType:{TypePath} namespace:Tpl/Widgets");
+ grouped.CreateHref.Should().Be(
+ $"/create?type={Uri.EscapeDataString(TypePath)}&namespace={Uri.EscapeDataString("Tpl/Widgets")}"
+ + $"&namespaces={Uri.EscapeDataString("Tpl/A")},{Uri.EscapeDataString("Tpl/B")}");
+
+ MeshNodeLayoutAreas.NodeTypeCatalogQuery.From(TypePath, null).Should().Be(plain,
+ "content that is not a definition lists the namespace subtree, as before");
+ }
+
+ [Fact]
+ public async Task SearchOnADefinition_EmitsTheTemplate_WithoutReadingTheNode()
{
var (stream, area) = Open(TypePath, MeshNodeLayoutAreas.SearchArea);
- var search = await CatalogOf(stream, area);
+ var slot = Assert.IsType(
+ await Walk(stream, area).Should().Within(TestTimeouts.Convergence).Match(
+ c => c is LayoutAreaControl, "a definition's Search page is the frame with the instance slot"));
- search!.HiddenQuery!.ToString().Should().Contain($"namespace:{TypePath}",
- "a definition's hub is recognised from its configuration and still lists its instances");
+ slot.Reference.Area.Should().Be(MeshNodeLayoutAreas.NodeTypeInstancesArea);
+ }
+
+ [Fact]
+ public async Task TheInstanceSlot_CatalogsTheInstances_AndFollowsTheDefinition()
+ {
+ var (stream, area) = Open(TypePath, MeshNodeLayoutAreas.NodeTypeInstancesArea);
+
+ await QueryOf(stream, area).Should().Within(TestTimeouts.Convergence).Match(
+ q => q != null && q.Contains($"namespace:{TypePath}"),
+ "a definition's hub lists its instances by a query the GUI runs");
+
+ await Mesh.GetMeshNodeStream(TypePath)
+ .Update(n => n with { Content = new NodeTypeDefinition { Description = "A widget type", DefaultNamespace = "Tpl/Widgets" } })
+ .Should().Within(TestTimeouts.Convergence).Emit();
+ await QueryOf(stream, area).Should().Within(TestTimeouts.Convergence).Match(
+ q => q == $"nodeType:{TypePath} namespace:Tpl/Widgets",
+ "the slot follows a later edit of the definition — never a snapshot");
}
[Fact]
@@ -249,6 +304,10 @@ public async Task SearchOnAnInstance_IsTheOrdinaryCatalog()
=> await Walk(stream, area).Should().Within(TestTimeouts.Convergence).Match(c => c is MeshSearchControl,
"the Search area shows a catalog") as MeshSearchControl;
+ /// The hidden query of every catalog the area renders, as it changes.
+ private static IObservable QueryOf(ISynchronizationStream stream, string area)
+ => Walk(stream, area).OfType().Select(s => s.HiddenQuery?.ToString());
+
/// Every control rendered at and, recursively, in its sub-areas.
private static IObservable Walk(ISynchronizationStream stream, string area)
=> stream.GetControlStream(area)