diff --git a/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia.md b/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia.md index ff6c33b065..7f166bbfbf 100644 --- a/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia.md +++ b/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia.md @@ -24,7 +24,7 @@ Doc/DataMesh/SocialMedia/ Source/ # C# compiled at startup Platform.cs # Reference-data record SocialMediaPost.cs # Content record - SocialMediaPostLayoutAreas.cs # List + Detail layout areas + SocialMediaPostLayoutAreas.cs # List + Row + Detail layout areas Post-001.json # Instance (nodeType: "Doc/DataMesh/SocialMedia/Post") Profile.json # Second NodeType Profile/ @@ -163,24 +163,41 @@ public record SocialMediaPost ## 3. Layout Areas — `SocialMediaPostLayoutAreas.cs` -Layout areas are the **views** for instances of the type. They return `IObservable` — never `Task<…>`, never `async`. Compose with Rx operators: +Layout areas are the **views** for instances of the type, and every one of them is a **template** ([Templates first, data later](/Doc/GUI/DataBinding)): it is on screen at the first render and BINDS its data instead of loading it on the hub — never `Task<…>`, never `async`, and never a `GetMeshNodeStream()`/query `.Select(… => Controls…)` that waits for the data and bakes it in. + +- **A list** is a query the GUI runs — `Controls.MeshSearch` — and each result renders through the post's OWN `Row` area, so the list's hub reads no post at all. +- **A stored field** is a `JsonPointerReference` into the node, resolved by the GUI through the node stream: `DataContext = LayoutAreaReference.GetMeshNodeDataContext(path)` for the content, `…(path, bindContent: false)` for the node's own `name`. +- **A derived value** (the Published / Scheduled / Draft status) comes from a FEED — a function that builds no control — bound into a control declared up front with `Template.Bind`. ```csharp -public static IObservable List(LayoutAreaHost host, RenderingContext _) +public static UiControl List(LayoutAreaHost host, RenderingContext _) => + Controls.Stack + .WithView(Controls.H2("Posts")) + .WithView(Controls.MeshSearch + .WithHiddenQuery("namespace:Doc/DataMesh/SocialMedia/Post nodeType:Doc/DataMesh/SocialMedia/Post sort:content.scheduledAt-desc") + .WithShowSearchBox(false) + .WithItemArea("Row")); + +public static UiControl Detail(LayoutAreaHost host, RenderingContext _) { - var meshService = host.Hub.ServiceProvider.GetRequiredService(); - return meshService - .Query(MeshQueryRequest.FromQuery("namespace:Doc/DataMesh/SocialMedia/Post")) - .Scan(ImmutableDictionary.Empty, ApplyChanges) - .Select(dict => (UiControl?)BuildList(dict.Values.ToImmutableList())); + var path = host.Hub.Address.ToString(); + var content = LayoutAreaReference.GetMeshNodeDataContext(path); + return Controls.Stack + .WithView(Controls.H1(new JsonPointerReference("name")) with + { DataContext = LayoutAreaReference.GetMeshNodeDataContext(path, bindContent: false) }) + .WithView(Controls.Badge(new JsonPointerReference("platform")) with { DataContext = content }) + .WithView(StatusFeed(host).Bind(text => Controls.Badge(text), "postStatus")) + .WithView(Controls.Markdown(new JsonPointerReference("body")) with { DataContext = content }); } ``` +Each type's `Test/` folder asserts this on the mesh: the template is built from a path (with a feed that never emits) and `LayoutTemplate.DeferredViews(template)` must be empty. + The extension method below is how the layout areas get wired into the NodeType configuration: ```csharp public static LayoutDefinition AddSocialMediaPostLayoutAreas(this LayoutDefinition layout) => - layout.WithView("List", List).WithView("Detail", Detail); + layout.WithView("List", List).WithView("Row", Row).WithView("Detail", Detail); ``` --- @@ -263,7 +280,7 @@ When building a new model node type "as code", work through this list in order: 1. ☐ Create a namespace folder under your target location. 2. ☐ Add one `.cs` per content record in `Source/`, each with the `` frontmatter. 3. ☐ Add reference-data `.cs` files with `[Key]`, static instances, and `All[]`. -4. ☐ Add a `XxxLayoutAreas.cs` with `List`/`Detail` views returning `IObservable`. +4. ☐ Add a `XxxLayoutAreas.cs` with `List`/`Detail` views that are TEMPLATES — a `UiControl` built from the node's path, values bound by pointer, lists as `Controls.MeshSearch` — never a view that loads the node and bakes its values in. 5. ☐ Write the `Type.json` with `nodeType: "NodeType"` and a configuration lambda. 6. ☐ Write **at least one** instance JSON with `nodeType` set to the namespace-qualified path. 7. ☐ **Do not** substitute a Markdown node for a typed view — Markdown is for documents, not structured data. diff --git a/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Post.json b/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Post.json index 6fd3583aa6..9506130025 100644 --- a/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Post.json +++ b/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Post.json @@ -13,6 +13,6 @@ "namespace": "Doc/DataMesh/SocialMedia", "displayName": "Social Media Post", "description": "A social media post (scheduled, published, with engagement stats)", - "configuration": "config => config.WithContentType().AddData(data => data.AddSource(source => source.WithType(t => t.WithInitialData(Platform.All))).WithVirtualDataSource(\"SocialMediaPosts\", vs => vs.WithVirtualType(workspace => SocialMediaPostLayoutAreas.LoadPosts(workspace)))).AddDefaultLayoutAreas().AddLayout(layout => layout.AddSocialMediaPostLayoutAreas().WithDefaultArea(\"List\"))" + "configuration": "config => config.WithContentType().AddData(data => data.AddSource(source => source.WithType(t => t.WithInitialData(Platform.All)))).AddDefaultLayoutAreas().AddLayout(layout => layout.AddSocialMediaPostLayoutAreas().WithView(\"Tests\", PostViewTests.Tests).WithDefaultArea(\"List\"))" } } diff --git a/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Post/Source/SocialMediaPostLayoutAreas.cs b/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Post/Source/SocialMediaPostLayoutAreas.cs index c756e9e9cf..beb24ef60f 100644 --- a/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Post/Source/SocialMediaPostLayoutAreas.cs +++ b/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Post/Source/SocialMediaPostLayoutAreas.cs @@ -3,220 +3,161 @@ // DisplayName: Social Media Post Views // -using System.Collections.Immutable; -using System.Reactive.Linq; -using System.Text.Json; -using System.Web; -using MeshWeaver.Data; +using System.Globalization; using MeshWeaver.Layout.Composition; -using MeshWeaver.Mesh.Services; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; /// -/// Workspace projection of a sibling Post node: the display order plus the raw -/// the List view formats. Keyed by so -/// the virtual collection stays deduplicated as the query result set evolves. +/// The Post's views — all TEMPLATES (Doc/GUI/DataBinding → "Templates first, data later"): each is +/// on screen at the first render and binds its data instead of loading it on the hub. +/// +/// List is a : the GUI runs the query, and every result +/// renders through that post's OWN Row area — so the list's hub reads no post at all. +/// Row and Detail bind the post's stored fields by pointer; the one value the hub has +/// to DERIVE — the Published / Scheduled / Draft status — comes from , a +/// function that builds no control, bound into a badge declared up front. /// -public record PostNode -{ - [Key] - public string Path { get; init; } = string.Empty; - public MeshNode Node { get; init; } = null!; -} - public static class SocialMediaPostLayoutAreas { + /// The list view. public const string ListArea = "List"; + + /// One post as a list row — what renders per result. + public const string RowArea = "Row"; + + /// The post's detail view. public const string DetailArea = "Detail"; + /// Where the posts live. + public const string PostsNamespace = "Doc/DataMesh/SocialMedia/Post"; + + /// The /data id the status feed writes to. + public const string StatusDataId = "postStatus"; + + /// Registers the post views. + /// The layout definition. + /// The layout definition with the views added. public static LayoutDefinition AddSocialMediaPostLayoutAreas(this LayoutDefinition layout) => layout .WithView(ListArea, List) + .WithView(RowArea, Row) .WithView(DetailArea, Detail); - private static ImmutableDictionary ApplyChanges( - ImmutableDictionary current, QueryResultChange change) - { - var result = change.ChangeType == QueryChangeType.Initial || change.ChangeType == QueryChangeType.Reset - ? ImmutableDictionary.Empty.WithComparers(StringComparer.OrdinalIgnoreCase) - : current; - foreach (var item in change.Items) - result = change.ChangeType == QueryChangeType.Removed - ? result.Remove(item.Path) - : result.SetItem(item.Path, item); - return result; - } - - private static string? GetProp(MeshNode node, string prop) - { - if (node.Content is not JsonElement json) return null; - if (json.TryGetProperty(prop, out var p) && p.ValueKind == JsonValueKind.String) return p.GetString(); - var pascal = char.ToUpperInvariant(prop[0]) + prop.Substring(1); - return json.TryGetProperty(pascal, out var pp) && pp.ValueKind == JsonValueKind.String ? pp.GetString() : null; - } - - private static int GetInt(MeshNode node, string prop) + /// Every post, newest schedule first. + /// The area host; nothing of it is read. + /// The rendering context. + /// The list template. + public static UiControl List(LayoutAreaHost host, RenderingContext _) => ListTemplate(); + + /// One post as a row: title, platform, dates and counts. + /// The area host; the status feed reads its node. + /// The rendering context. + /// The row template. + public static UiControl Row(LayoutAreaHost host, RenderingContext _) + => RowTemplate(host.Hub.Address.ToString(), StatusFeed(host)); + + /// The post: title, platform and status, dates, counts and body. + /// The area host; the status feed reads its node. + /// The rendering context. + /// The detail template. + public static UiControl Detail(LayoutAreaHost host, RenderingContext _) + => DetailTemplate(host.Hub.Address.ToString(), StatusFeed(host)); + + /// The list: a query the GUI runs, each result drawn by the post's own . + /// The complete control tree — it never waits on data. + public static UiControl ListTemplate() => + Controls.Stack + .WithStyle("padding: 16px; gap: 12px;") + .WithView(Controls.H2("Posts")) + .WithView(Controls.MeshSearch + .WithHiddenQuery(PostsQuery) + .WithShowSearchBox(false) + .WithItemArea(RowArea) + .WithGridBreakpoints(xs: 12, sm: 12, md: 12, lg: 12)); + + /// The query the list runs: the posts, newest schedule first. + public const string PostsQuery = $"namespace:{PostsNamespace} nodeType:{PostsNamespace} sort:content.scheduledAt-desc"; + + /// The post at as one row, bound by path. + /// The post node. + /// The derived status (), as it changes. + /// The complete control tree — it never waits on data. + public static UiControl RowTemplate(string nodePath, IObservable status) { - if (node.Content is not JsonElement json) return 0; - if (!json.TryGetProperty(prop, out var p)) - { - var pascal = char.ToUpperInvariant(prop[0]) + prop.Substring(1); - if (!json.TryGetProperty(pascal, out p)) return 0; - } - return p.ValueKind == JsonValueKind.Number && p.TryGetInt32(out var v) ? v : 0; + var content = LayoutAreaReference.GetMeshNodeDataContext(nodePath); + return Controls.Stack.WithOrientation(Orientation.Horizontal) + .WithStyle("gap: 16px; align-items: center; flex-wrap: wrap;") + .WithView(Controls.Label(new JsonPointerReference("name")) with + { + DataContext = LayoutAreaReference.GetMeshNodeDataContext(nodePath, bindContent: false) + }) + .WithView(Controls.Badge(new JsonPointerReference("platform")) with { DataContext = content }) + .WithView(status.Bind(text => Controls.Badge(text), StatusDataId)) + .WithView(Field("Scheduled", "scheduledAt", content)) + .WithView(Field("Likes", "likes", content)) + .WithView(Field("Impressions", "impressions", content)); } - private static DateTimeOffset? GetDate(MeshNode node, string prop) + /// The post at , bound by path. + /// The post node. + /// The derived status (), as it changes. + /// The complete control tree — it never waits on data. + public static UiControl DetailTemplate(string nodePath, IObservable status) { - if (node.Content is not JsonElement json) return null; - if (!json.TryGetProperty(prop, out var p)) - { - var pascal = char.ToUpperInvariant(prop[0]) + prop.Substring(1); - if (!json.TryGetProperty(pascal, out p)) return null; - } - return p.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(p.GetString(), out var dt) ? dt : null; + var content = LayoutAreaReference.GetMeshNodeDataContext(nodePath); + return Controls.Stack + .WithStyle("padding: 16px; gap: 8px;") + .WithView(Controls.H1(new JsonPointerReference("name")) with + { + DataContext = LayoutAreaReference.GetMeshNodeDataContext(nodePath, bindContent: false) + }) + .WithView(Controls.Stack.WithOrientation(Orientation.Horizontal).WithStyle("gap: 12px;") + .WithView(Controls.Badge(new JsonPointerReference("platform")) with { DataContext = content }) + .WithView(status.Bind(text => Controls.Badge(text), StatusDataId))) + .WithView(Field("Scheduled", "scheduledAt", content)) + .WithView(Field("Published", "publishedAt", content)) + .WithView(Controls.Stack.WithOrientation(Orientation.Horizontal).WithStyle("gap: 24px;") + .WithView(Field("Likes", "likes", content)) + .WithView(Field("Impressions", "impressions", content))) + .WithView(Controls.Markdown(new JsonPointerReference("body")) with { DataContext = content }); } /// - /// Virtual-source loader: the namespace:Doc/DataMesh/SocialMedia/Post - /// mesh query, folded to a path-keyed snapshot and projected to - /// . Registered in Post.json's configuration - /// via WithVirtualDataSource so the framework subscribes it ONCE at - /// hub initialization on a managed scheduler — never from inside a view - /// render (a query subscription in a layout-area render runs on the grain's - /// activation thread and deadlocks the hub — see AsynchronousCalls). The - /// List view then composes only the hub's OWN workspace stream. + /// Published once it has a publish time; Scheduled while its schedule lies ahead; else Draft. + /// Pure. now is UTC: the stored times are instants, so comparing them to a local clock + /// would flip the status by the host's offset. /// - public static IObservable> LoadPosts(IWorkspace workspace) - => workspace.Hub.ServiceProvider.GetRequiredService() - .Query(MeshQueryRequest.FromQuery("namespace:Doc/DataMesh/SocialMedia/Post")) - .Scan(ImmutableDictionary.Empty.WithComparers(StringComparer.OrdinalIgnoreCase), ApplyChanges) - .Select(dict => dict.Values - .Select(n => new PostNode { Path = n.Path, Node = n }) - .ToImmutableList() - .AsEnumerable()); - - public static IObservable List(LayoutAreaHost host, RenderingContext _) - { - var postsStream = host.Workspace.GetStream() - ?? Observable.Return(null); - - return postsStream.Select(posts => (UiControl?)BuildList( - host, - (posts ?? Enumerable.Empty()) - .Select(p => p.Node) - .ToImmutableList())); - } - - private static UiControl BuildList(LayoutAreaHost host, ImmutableList posts) - { - var ordered = posts - .OrderByDescending(p => GetDate(p, "scheduledAt") ?? DateTimeOffset.MinValue) - .ToImmutableList(); - - if (ordered.Count == 0) - return Controls.Stack - .WithStyle("padding: 16px;") - .WithView(Controls.Markdown(host.Localize("ui.mdNoPosts"))); - - var rows = string.Join("", ordered.Select(p => - { - var title = p.Name ?? GetProp(p, "title") ?? "(untitled)"; - var platformId = GetProp(p, "platform") ?? "LinkedIn"; - var platform = Platform.GetById(platformId); - var scheduled = GetDate(p, "scheduledAt")?.ToString("yyyy-MM-dd HH:mm") ?? "\u2014"; - var published = GetDate(p, "publishedAt") is { } d ? d.ToString("yyyy-MM-dd HH:mm") : "\u2014"; - var likes = GetInt(p, "likes"); - var impressions = GetInt(p, "impressions"); - return $""" - - {HttpUtility.HtmlEncode(title)} - {platform.Emoji} {HttpUtility.HtmlEncode(platform.Name)} - {scheduled} - {published} - {likes:N0} - {impressions:N0} - - """; - })); - - var table = $""" - - - - - - - - - - - - {rows} -
TitlePlatformScheduledPublishedLikesImpressions
- """; + /// The post, or null when the node carries none. + /// The current instant. + /// The status text. + public static string Status(SocialMediaPost? post, DateTimeOffset now) => + post is null ? "Draft" + : post.PublishedAt.HasValue ? "Published" + : post.ScheduledAt > now ? "Scheduled" + : "Draft"; - return Controls.Stack - .WithStyle("padding: 16px; gap: 12px;") - .WithView(Controls.Html($"

Posts

")) - .WithView(Controls.Html(table)); - } - - public static IObservable Detail(LayoutAreaHost host, RenderingContext _) - { - var hubPath = host.Hub.Address.ToString(); - - return host.Workspace.GetStream()! - .Select(nodes => nodes?.FirstOrDefault(n => n.Path == hubPath)) - .Select(node => + /// + /// The FEED half: the post's on every emission of its node. Builds no + /// control. A failure is logged and shown as text — reported, never swallowed. + /// + /// The area host whose node is read. + /// The status text. + public static IObservable StatusFeed(LayoutAreaHost host) => + host.Workspace.GetMeshNodeStream() + .Select(node => Status(node.ContentAs(host.Hub.JsonSerializerOptions), DateTimeOffset.UtcNow)) + .DistinctUntilChanged() + .Catch(ex => { - if (node is null) - return (UiControl?)Controls.Markdown(host.Localize("ui.mdPostNotFound")); - - var title = node.Name ?? GetProp(node, "title") ?? "(untitled)"; - var body = GetProp(node, "body"); - var platformId = GetProp(node, "platform") ?? "LinkedIn"; - var platform = Platform.GetById(platformId); - var scheduled = GetDate(node, "scheduledAt"); - var published = GetDate(node, "publishedAt"); - var status = published.HasValue ? "Published" - // UtcNow, not Now: scheduledAt is a stored UTC instant, so comparing it to the - // server's local clock flips the badge by the host offset. - : (scheduled.HasValue && scheduled.Value > DateTimeOffset.UtcNow ? "Scheduled" : "Draft"); - var statusColor = published.HasValue ? "#2e7d32" : "#ed6c02"; - var impressions = GetInt(node, "impressions"); - var likes = GetInt(node, "likes"); - - var header = $$""" -
- {{platform.Emoji}} {{HttpUtility.HtmlEncode(platform.Name)}} - {{status}} -
- """; - - var dates = $$""" - - - -
Scheduled{{HttpUtility.HtmlEncode(scheduled?.ToString("yyyy-MM-dd HH:mm") ?? "\u2014")}}
Published{{HttpUtility.HtmlEncode(published?.ToString("yyyy-MM-dd HH:mm") ?? "\u2014")}}
- """; - - var stats = $$""" -
-
Likes
{{likes:N0}}
-
Impressions
{{impressions:N0}}
-
- """; - - var stack = Controls.Stack - .WithStyle("padding: 16px; gap: 8px;") - .WithView(Controls.Html($"

{HttpUtility.HtmlEncode(title)}

")) - .WithView(Controls.Html(header)) - .WithView(Controls.Html(dates)) - .WithView(Controls.Html(stats)); - if (!string.IsNullOrWhiteSpace(body)) - stack = stack.WithView(Controls.Markdown(body)); - return (UiControl?)stack; + host.Hub.ServiceProvider.GetRequiredService() + .CreateLogger(nameof(SocialMediaPostLayoutAreas)) + .LogWarning(ex, "The status of post {Path} could not be read", host.Hub.Address); + return Observable.Return("Status could not be read"); }); - } + + /// A caption over a value bound into the content. + private static UiControl Field(string caption, string pointer, string content) => + Controls.Stack + .WithView(Controls.Label(caption).WithStyle("font-size: 11px; color: var(--neutral-foreground-hint);")) + .WithView(Controls.Label(new JsonPointerReference(pointer)) with { DataContext = content }); } diff --git a/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Post/Test/PostViewTests.cs b/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Post/Test/PostViewTests.cs new file mode 100644 index 0000000000..c87e23c265 --- /dev/null +++ b/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Post/Test/PostViewTests.cs @@ -0,0 +1,138 @@ +// +// Id: PostViewTests +// DisplayName: Post Views Tests — the views are templates +// +#nullable enable +using System; +using System.Collections.Immutable; +using System.Globalization; +using System.Linq; +using System.Reactive.Linq; +using MeshWeaver.Data; +using MeshWeaver.Layout; +using MeshWeaver.Layout.Composition; +using MeshWeaver.Layout.DataGrid; + +/// +/// The cases of Doc/DataMesh/SocialMedia/Post's views: each view is a TEMPLATE (Doc/GUI/DataBinding → +/// "Templates first, data later") — built without the node, its first emission is the whole page, +/// with no view deferred until data arrives — and it binds the node by PATH. Pure cases: they +/// build the templates from a path and THROW on failure. runs them; a case not +/// listed there runs nowhere. +/// +public static class PostViewTests +{ + private const string NodePath = "Doc/DataMesh/SocialMedia/Post/Post-001"; + + /// The Tests view — registered in the NodeType's configuration as WithView("Tests", …). + /// The layout area host. + /// The rendering context. + /// One verdict frame: the N/M passed title and a row per case. + public static UiControl Tests(LayoutAreaHost host, RenderingContext context) + { + var cases = new (string Name, Action Body)[] + { + ("The list is a query the GUI runs; each post draws its own row.", List_IsAQueryTheGuiRuns), + ("A row is whole while its status feed is silent, and binds the post by path.", Row_IsATemplateBoundByPath), + ("The detail is whole while its status feed is silent, and binds the post by path.", Detail_IsATemplateBoundByPath), + ("The status: published wins, a future schedule is scheduled, anything else a draft.", Status_IsDerivedFromTheDates), + }; + var rows = cases.Select(c => Run(c.Name, c.Body)).ToImmutableList(); + return Controls.Stack + .WithView(Controls.Markdown($"### Post views tests — {rows.Count(r => r.Passed)}/{rows.Count} passed")) + .WithView(host.ToDataGrid(rows)); + } + + /// The list is a query the GUI runs; each post draws its own row. + public static void List_IsAQueryTheGuiRuns() + { + var template = SocialMediaPostLayoutAreas.ListTemplate(); + ExpectTemplate(template, "List"); + var search = LayoutTemplate.Descendants(template).OfType().SingleOrDefault(); + Expect(search is not null, "the posts are a MeshSearch, not rows built on the hub"); + Expect(Equals(search!.HiddenQuery, SocialMediaPostLayoutAreas.PostsQuery), $"the list runs the posts query, got {search.HiddenQuery}"); + Expect(Equals(search.ItemArea, SocialMediaPostLayoutAreas.RowArea), "each result renders through the post's own Row area"); + } + + /// A row is whole while its status feed is silent, and binds the post by path. + public static void Row_IsATemplateBoundByPath() + { + var template = SocialMediaPostLayoutAreas.RowTemplate(NodePath, Observable.Never()); + ExpectTemplate(template, "Row"); + var content = LayoutAreaReference.GetMeshNodeDataContext(NodePath); + var bindings = Bindings(template); + Expect(bindings.Contains(("name", LayoutAreaReference.GetMeshNodeDataContext(NodePath, bindContent: false))), "the title is the node's name"); + foreach (var pointer in new[] { "scheduledAt", "likes", "impressions" }) + Expect(bindings.Contains((pointer, content)), $"the row binds '{pointer}' in the content"); + Expect(LayoutTemplate.Descendants(template).OfType().Any(b => b.DataContext == LayoutAreaReference.GetDataPointer(SocialMediaPostLayoutAreas.StatusDataId)), + "the status badge is declared up front and fed through /data"); + } + + /// The detail is whole while its status feed is silent, and binds the post by path. + public static void Detail_IsATemplateBoundByPath() + { + var template = SocialMediaPostLayoutAreas.DetailTemplate(NodePath, Observable.Never()); + ExpectTemplate(template, "Detail"); + var content = LayoutAreaReference.GetMeshNodeDataContext(NodePath); + var bindings = Bindings(template); + foreach (var pointer in new[] { "scheduledAt", "publishedAt", "likes", "impressions" }) + Expect(bindings.Contains((pointer, content)), $"the detail binds '{pointer}' in the content"); + Expect(LayoutTemplate.Descendants(template).OfType().Any(m => m.Markdown is JsonPointerReference { Pointer: "body" } && m.DataContext == content), + "the body is a pointer into the content, never its text"); + } + + /// The status: published wins, a future schedule is scheduled, anything else a draft. + public static void Status_IsDerivedFromTheDates() + { + var now = new DateTimeOffset(2026, 10, 1, 12, 0, 0, TimeSpan.Zero); + var post = new SocialMediaPost { ScheduledAt = now.AddHours(1) }; + Expect(SocialMediaPostLayoutAreas.Status(post, now) == "Scheduled", "a schedule ahead is scheduled"); + Expect(SocialMediaPostLayoutAreas.Status(post with { ScheduledAt = now.AddHours(-1) }, now) == "Draft", "a lapsed schedule without a publish is a draft"); + Expect(SocialMediaPostLayoutAreas.Status(post with { PublishedAt = now }, now) == "Published", "a publish time wins"); + Expect(SocialMediaPostLayoutAreas.Status(null, now) == "Draft", "no content is a draft"); + } + + /// The node-bound pointers of the tree's labels, as (pointer, data context). + private static ImmutableList<(string Pointer, string? Context)> Bindings(UiControl root) => + LayoutTemplate.Descendants(root) + .OfType() + .Where(label => label.Data is JsonPointerReference) + .Select(label => (((JsonPointerReference)label.Data).Pointer, label.DataContext)) + .ToImmutableList(); + + private static void ExpectTemplate(UiControl template, string view) + { + var deferred = LayoutTemplate.DeferredViews(template); + Expect(deferred.IsEmpty, $"{view} defers {deferred.Count} view(s) until data arrives: {string.Join(", ", deferred)}"); + Expect(!LayoutTemplate.Descendants(template).OfType().Any(), + $"{view} renders hand-built HTML; compose platform controls instead"); + } + + private static PostViewTestsRow Run(string name, Action body) + { + try + { + body(); + return new(name, "✅ pass"); + } + catch (Exception ex) + { + return new(name, "❌ " + ex.Message.Replace('\n', ' ')); + } + } + + private static void Expect(bool condition, string because) + { + if (!condition) + throw new InvalidOperationException(because); + } +} + +/// One executed case, as a grid row. +/// What the case asserts. +/// ✅, or ❌ with the failure message. +public sealed record PostViewTestsRow(string Case, string Result) +{ + /// Whether the case passed. + public bool Passed => Result.StartsWith("✅", StringComparison.Ordinal); +} diff --git a/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Profile.json b/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Profile.json index f2dc1861ec..f8334bbb9a 100644 --- a/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Profile.json +++ b/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Profile.json @@ -13,6 +13,6 @@ "namespace": "Doc/DataMesh/SocialMedia", "displayName": "Social Media Profile", "description": "A social media profile owned by a user", - "configuration": "config => config.WithContentType().AddData(data => data.AddSource(source => source.WithType(t => t.WithInitialData(Platform.All)))).AddDefaultLayoutAreas().AddLayout(layout => layout.AddSocialMediaProfileLayoutAreas().WithDefaultArea(\"Detail\"))" + "configuration": "config => config.WithContentType().AddData(data => data.AddSource(source => source.WithType(t => t.WithInitialData(Platform.All)))).AddDefaultLayoutAreas().AddLayout(layout => layout.AddSocialMediaProfileLayoutAreas().WithView(\"Tests\", ProfileViewTests.Tests).WithDefaultArea(\"Detail\"))" } } diff --git a/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Profile/Source/SocialMediaProfileLayoutAreas.cs b/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Profile/Source/SocialMediaProfileLayoutAreas.cs index d7a18a6d2e..b46920b6a5 100644 --- a/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Profile/Source/SocialMediaProfileLayoutAreas.cs +++ b/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Profile/Source/SocialMediaProfileLayoutAreas.cs @@ -5,66 +5,52 @@ using System; using System.Linq; -using System.Text.Json; -using System.Web; using MeshWeaver.Layout.Composition; +/// +/// The Profile's view — a TEMPLATE (Doc/GUI/DataBinding → "Templates first, data later"): built +/// from the node's PATH, emitted whole on the first render, and every value a pointer into the +/// node's that the GUI resolves through the node stream. Nothing +/// here reads the node on the hub — and so nothing here has to dig a value out of a +/// JsonElement either. +/// public static class SocialMediaProfileLayoutAreas { + /// The area name of the profile's detail view. public const string DetailArea = "Detail"; + /// Registers the profile views. + /// The layout definition. + /// The layout definition with the views added. public static LayoutDefinition AddSocialMediaProfileLayoutAreas(this LayoutDefinition layout) => layout.WithView(DetailArea, Detail); - private static string? GetProp(MeshNode node, string prop) - { - if (node.Content is not JsonElement json) return null; - if (json.TryGetProperty(prop, out var p) && p.ValueKind == JsonValueKind.String) return p.GetString(); - var pascal = char.ToUpperInvariant(prop[0]) + prop.Substring(1); - return json.TryGetProperty(pascal, out var pp) && pp.ValueKind == JsonValueKind.String ? pp.GetString() : null; - } + /// The profile: name, platform, owner, link and bio. + /// The area host; only its address is read. + /// The rendering context. + /// The detail template. + public static UiControl Detail(LayoutAreaHost host, RenderingContext _) + => DetailTemplate(host.Hub.Address.ToString()); - public static IObservable Detail(LayoutAreaHost host, RenderingContext _) + /// The profile at , bound by path. + /// The profile node. + /// The complete control tree — it never waits on data. + public static UiControl DetailTemplate(string nodePath) { - var hubPath = host.Hub.Address.ToString(); - - return host.Workspace.GetStream()! - .Select(nodes => nodes?.FirstOrDefault(n => n.Path == hubPath)) - .Select(node => + var content = LayoutAreaReference.GetMeshNodeDataContext(nodePath); + return Controls.Stack + .WithStyle("padding: 16px; gap: 8px;") + .WithView(Controls.H2(new JsonPointerReference("name")) with { - if (node is null) - return (UiControl?)Controls.Markdown(host.Localize("ui.mdProfileNotFound")); - - var name = node.Name ?? GetProp(node, "name") ?? "Profile"; - var platformId = GetProp(node, "platform") ?? "LinkedIn"; - var platform = Platform.GetById(platformId); - var owner = GetProp(node, "owner") ?? ""; - var profileUrl = GetProp(node, "profileUrl"); - var bio = GetProp(node, "bio"); - - var link = !string.IsNullOrEmpty(profileUrl) - ? $"Open profile \u2197" - : "No profile URL"; - - var html = $$""" -
-
{{platform.Emoji}}
-
-

{{HttpUtility.HtmlEncode(name)}}

-
{{platform.Emoji}} {{HttpUtility.HtmlEncode(platform.Name)}}
-
Owner: {{HttpUtility.HtmlEncode(owner)}}
-
{{link}}
-
-
- """; - - var stack = Controls.Stack - .WithStyle("padding: 16px;") - .WithView(Controls.Html(html)); - if (!string.IsNullOrWhiteSpace(bio)) - stack = stack.WithView(Controls.Markdown(bio)); - return (UiControl?)stack; - }); + DataContext = LayoutAreaReference.GetMeshNodeDataContext(nodePath, bindContent: false) + }) + .WithView(Controls.Badge(new JsonPointerReference("platform")) with { DataContext = content }) + .WithView(Controls.Stack.WithOrientation(Orientation.Horizontal).WithStyle("gap: 8px;") + .WithView(Controls.Label("Owner:").WithStyle("color: var(--neutral-foreground-hint);")) + .WithView(Controls.Label(new JsonPointerReference("owner")) with { DataContext = content })) + // A bare URL renders as a link in markdown. + .WithView(Controls.Markdown(new JsonPointerReference("profileUrl")) with { DataContext = content }) + .WithView(Controls.Markdown(new JsonPointerReference("bio")) with { DataContext = content }); } } diff --git a/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Profile/Test/ProfileViewTests.cs b/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Profile/Test/ProfileViewTests.cs new file mode 100644 index 0000000000..54ccca527b --- /dev/null +++ b/src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Profile/Test/ProfileViewTests.cs @@ -0,0 +1,102 @@ +// +// Id: ProfileViewTests +// DisplayName: Profile Views Tests — the detail is a template bound by path +// +#nullable enable +using System; +using System.Collections.Immutable; +using System.Globalization; +using System.Linq; +using System.Reactive.Linq; +using MeshWeaver.Data; +using MeshWeaver.Layout; +using MeshWeaver.Layout.Composition; +using MeshWeaver.Layout.DataGrid; + +/// +/// The cases of Doc/DataMesh/SocialMedia/Profile's views: each view is a TEMPLATE (Doc/GUI/DataBinding → +/// "Templates first, data later") — built without the node, its first emission is the whole page, +/// with no view deferred until data arrives — and it binds the node by PATH. Pure cases: they +/// build the templates from a path and THROW on failure. runs them; a case not +/// listed there runs nowhere. +/// +public static class ProfileViewTests +{ + private const string NodePath = "Doc/DataMesh/SocialMedia/Profile/Roland-LinkedIn"; + + /// The Tests view — registered in the NodeType's configuration as WithView("Tests", …). + /// The layout area host. + /// The rendering context. + /// One verdict frame: the N/M passed title and a row per case. + public static UiControl Tests(LayoutAreaHost host, RenderingContext context) + { + var cases = new (string Name, Action Body)[] + { + ("The detail is a template bound to the profile by path: name, platform, owner, link, bio.", Detail_IsATemplateBoundByPath), + }; + var rows = cases.Select(c => Run(c.Name, c.Body)).ToImmutableList(); + return Controls.Stack + .WithView(Controls.Markdown($"### Profile views tests — {rows.Count(r => r.Passed)}/{rows.Count} passed")) + .WithView(host.ToDataGrid(rows)); + } + + /// The detail is a template bound to the profile by path: name, platform, owner, link, bio. + public static void Detail_IsATemplateBoundByPath() + { + var template = SocialMediaProfileLayoutAreas.DetailTemplate(NodePath); + ExpectTemplate(template, "Detail"); + var content = LayoutAreaReference.GetMeshNodeDataContext(NodePath); + Expect(Bindings(template).Contains(("name", LayoutAreaReference.GetMeshNodeDataContext(NodePath, bindContent: false))), + "the heading binds the node's own name"); + Expect(Bindings(template).Contains(("owner", content)), "the owner is bound in the content"); + Expect(LayoutTemplate.Descendants(template).OfType().Any(b => b.Data is JsonPointerReference { Pointer: "platform" } && b.DataContext == content), + "the platform badge is bound in the content"); + var markdown = LayoutTemplate.Descendants(template).OfType() + .Where(m => m.DataContext == content).Select(m => (m.Markdown as JsonPointerReference)?.Pointer).ToList(); + Expect(markdown.SequenceEqual(new[] { "profileUrl", "bio" }), $"the link and the bio are bound, got {string.Join(", ", markdown)}"); + } + + /// The node-bound pointers of the tree's labels, as (pointer, data context). + private static ImmutableList<(string Pointer, string? Context)> Bindings(UiControl root) => + LayoutTemplate.Descendants(root) + .OfType() + .Where(label => label.Data is JsonPointerReference) + .Select(label => (((JsonPointerReference)label.Data).Pointer, label.DataContext)) + .ToImmutableList(); + + private static void ExpectTemplate(UiControl template, string view) + { + var deferred = LayoutTemplate.DeferredViews(template); + Expect(deferred.IsEmpty, $"{view} defers {deferred.Count} view(s) until data arrives: {string.Join(", ", deferred)}"); + Expect(!LayoutTemplate.Descendants(template).OfType().Any(), + $"{view} renders hand-built HTML; compose platform controls instead"); + } + + private static ProfileViewTestsRow Run(string name, Action body) + { + try + { + body(); + return new(name, "✅ pass"); + } + catch (Exception ex) + { + return new(name, "❌ " + ex.Message.Replace('\n', ' ')); + } + } + + private static void Expect(bool condition, string because) + { + if (!condition) + throw new InvalidOperationException(because); + } +} + +/// One executed case, as a grid row. +/// What the case asserts. +/// ✅, or ❌ with the failure message. +public sealed record ProfileViewTestsRow(string Case, string Result) +{ + /// Whether the case passed. + public bool Passed => Result.StartsWith("✅", StringComparison.Ordinal); +} diff --git a/src/MeshWeaver.Documentation/Data/GUI/LayoutAreas.md b/src/MeshWeaver.Documentation/Data/GUI/LayoutAreas.md index 57ebe3c335..808061ef83 100644 --- a/src/MeshWeaver.Documentation/Data/GUI/LayoutAreas.md +++ b/src/MeshWeaver.Documentation/Data/GUI/LayoutAreas.md @@ -153,9 +153,8 @@ Three rules travel with it: null** for the three cases that actually occur in a live mesh — untyped JSON from an unresolvable `$type`, the as-written `JsonObject` before materialization, and a same-named type from another collectible assembly (every NodeType recompile mints one). The view renders empty with no - exception and nothing to grep. Use `ContentAs(hub.JsonSerializerOptions)`. The shipped - `SocialMediaProfileLayoutAreas` sample predates this and hand-digs a `JsonElement` through a - `GetProp` helper — that helper *is* the symptom, not a pattern to copy. + exception and nothing to grep. Use `ContentAs(hub.JsonSerializerOptions)` — in a feed; a + view that only SHOWS a field binds it by pointer and never holds the content at all. - 🚨 **Never `.Take(1)`** on the stream feeding a view or a feed — it freezes the binding at the first emission and the area stops tracking the node. - **Writing back** is `GetMeshNodeStream(path).Update(current => current with { … })`, the only diff --git a/test/LayoutAreaDataBakeSites.allow b/test/LayoutAreaDataBakeSites.allow index f0e6019963..99b997062b 100644 --- a/test/LayoutAreaDataBakeSites.allow +++ b/test/LayoutAreaDataBakeSites.allow @@ -13,8 +13,6 @@ memex/Memex.Portal.Shared/Settings/UpdatePolicySettingsTab.cs 1 memex/Memex.Portal.Shared/Settings/WhatsNewSettingsTab.cs 1 samples/Graph/Data/ACME/Project/Source/ProjectLayoutAreas.cs 5 samples/Todo/MeshWeaver.Todo/LayoutAreas/TodoLayoutAreas.cs 7 -src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Post/Source/SocialMediaPostLayoutAreas.cs 1 -src/MeshWeaver.Documentation/Data/DataMesh/SocialMedia/Profile/Source/SocialMediaProfileLayoutAreas.cs 1 src/MeshWeaver.GitSync/GitHubSyncSettingsTab.cs 1 src/MeshWeaver.Graph/ExportLayoutArea.cs 1 src/MeshWeaver.Graph/GlobalSettingsLayoutArea.cs 1 diff --git a/test/MeshWeaver.Documentation.Test/LayoutAreaDataBakeRatchetGuard.cs b/test/MeshWeaver.Documentation.Test/LayoutAreaDataBakeRatchetGuard.cs index 63419dd1c4..fc68045c62 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 = 53; + private const int TotalBudget = 51; /// Reads that make a unit wait on data. internal static readonly Regex LoadPattern = new(