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)