From b80b27f44b8165c2649447c2fb1fbf3371213828 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 02:27:00 -0400 Subject: [PATCH] feat(adminui): B2-WP2 /raw page + lazy RawTree + context menus (modals stubbed) Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox --- .../Components/Layout/MainLayout.razor | 1 + .../Components/Pages/Raw/GlobalRaw.razor | 68 ++++ .../Components/Shared/Raw/RawStubModal.razor | 49 +++ .../Components/Shared/Raw/RawTree.razor | 301 ++++++++++++++++++ 4 files changed, 419 insertions(+) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Raw/GlobalRaw.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawStubModal.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTree.razor diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor index c5e29aa0..50b5ecdf 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor @@ -16,6 +16,7 @@ + diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Raw/GlobalRaw.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Raw/GlobalRaw.razor new file mode 100644 index 00000000..57af836f --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Raw/GlobalRaw.razor @@ -0,0 +1,68 @@ +@page "/raw" +@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)] +@rendermode RenderMode.InteractiveServer +@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns +@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw +@inject IRawTreeService Svc + +@* Peer of GlobalUns (/uns) for the v3 Raw project tree — the cluster-rooted + Enterprise → Cluster → Folder → Driver → Device → TagGroup → Tag hierarchy. + Loads the eager roots on init, then hands off to RawTree, which lazily expands + each container via IRawTreeService.LoadChildrenAsync and surfaces per-node + context menus (real create/configure/tag modals arrive in later Batch-2 waves). *@ + +Raw + +
+

Raw

+ Changes apply on the next deployment. +
+ +
+
+ Project tree +
+ + @if (_loading) + { +
+ Loading… +
+ } + else if (_loadError is not null) + { +
@_loadError
+ } + else if (_roots.Count == 0) + { +
No clusters yet.
+ } + else + { +
+ +
+ } +
+ +@code { + private IReadOnlyList _roots = Array.Empty(); + private bool _loading = true; + private string? _loadError; + + protected override async Task OnInitializedAsync() + { + try + { + _roots = await Svc.LoadRootsAsync(); + } + catch (Exception ex) + { + _loadError = $"Failed to load the Raw tree: {ex.Message}"; + } + finally + { + _loading = false; + } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawStubModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawStubModal.razor new file mode 100644 index 00000000..79aff451 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawStubModal.razor @@ -0,0 +1,49 @@ +@* Placeholder modal for the /raw context-menu actions whose real dialogs + (Configure / Device / TagModal / CSV import-export / Browse device / Rename) + land in later Batch-2 waves (B/C). This wave wires every menu action to a + named handler that opens this "coming soon" placeholder, so the tree + lazy + expansion + menus can be live-verified without the real modals existing yet. + Wave B/C replaces each handler body with the real modal invocation and can + then delete this component. Markup mirrors the GlobalUns delete-confirm modal. *@ + +@if (Visible) +{ + + +} + +@code { + /// Whether the placeholder is shown. + [Parameter] public bool Visible { get; set; } + + /// Modal title — the action name (e.g. "Configure driver"). + [Parameter] public string Title { get; set; } = ""; + + /// "Coming soon" body message. + [Parameter] public string Message { get; set; } = ""; + + /// The target node's display name, shown as context. + [Parameter] public string? NodeName { get; set; } + + /// Raised when the placeholder is dismissed. + [Parameter] public EventCallback OnClose { get; set; } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTree.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTree.razor new file mode 100644 index 00000000..ed14b6d6 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTree.razor @@ -0,0 +1,301 @@ +@* Recursive, lazily-expanding renderer for the v3 Raw project tree (/raw). + + Unlike UnsTree (which eager-loads the whole structural tree and never touches + the RawNode lazy fields), THIS component actually exercises the lazy plumbing: + expanding an unloaded container calls IRawTreeService.LoadChildrenAsync, appends + the returned level into node.Children and flips Loaded/Loading, showing a spinner + in flight and an error row on failure. Per-node context menus (right-click surface + + "⋯" fallback) are built per RawNodeKind; every action opens a placeholder + (RawStubModal) in this wave — Wave B/C replaces each named handler's body with the + real Configure / Device / TagModal / CSV / Browse modal. This component is a single + instance that recurses via the RenderNode RenderFragment, so it owns one stub modal + and one injected service for the whole tree. *@ +@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns +@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw +@inject IRawTreeService Svc + +@foreach (var root in Roots) +{ + @RenderNode(root, 0) +} + + + +@code { + /// The top-level Raw nodes to render (the enterprise roots from LoadRootsAsync). + [Parameter, EditorRequired] public IReadOnlyList Roots { get; set; } = default!; + + // --- Stub-placeholder state (one instance for the whole recursive tree) --- + private bool _stubVisible; + private string _stubTitle = ""; + private string _stubMessage = ""; + private string? _stubNodeName; + + // --------------------------------------------------------------------------- + // Row rendering + // --------------------------------------------------------------------------- + + private RenderFragment RenderNode(RawNode node, int depth) => __builder => + { + var indent = $"padding-left:{depth * 18}px"; + var hasExpander = node.HasLazyChildren || node.Children.Count > 0; + var muted = node.Enabled == false && node.Kind is RawNodeKind.Driver or RawNodeKind.Device; + var items = MenuFor(node); + +
+ @if (hasExpander) + { + + } + else + { + + } + + @if (items.Count > 0) + { + + @RenderRowBody(node, muted) + + } + else + { + @RenderRowBody(node, muted) + } +
+ + @if (node.Expanded && node.Loading) + { +
+ Loading… +
+ } + else if (node.Expanded && node.Error is not null) + { +
+ @node.Error +
+ } + else if (node.Expanded) + { + @foreach (var child in node.Children) + { + @RenderNode(child, depth + 1) + } + } + }; + + // The icon + label + badge shown for a node — the ContextMenu right-click surface. + private RenderFragment RenderRowBody(RawNode node, bool muted) => __builder => + { + + + @node.DisplayName + @if (muted) + { + disabled + } + @if (node.ChildCount > 0) + { + @node.ChildCount + } + + }; + + private static string KindIcon(RawNodeKind kind) => kind switch + { + RawNodeKind.Enterprise => "🏢", + RawNodeKind.Cluster => "🖥️", + RawNodeKind.Folder => "📁", + RawNodeKind.Driver => "🔌", + RawNodeKind.Device => "📟", + RawNodeKind.TagGroup => "🗂️", + RawNodeKind.Tag => "🏷️", + _ => "•", + }; + + // --------------------------------------------------------------------------- + // Lazy expansion — the whole point of WP2 (UNS never did this). + // --------------------------------------------------------------------------- + + /// + /// Toggles a node's expansion. On first expand of an unloaded container this sets + /// Loading, awaits , appends the + /// returned level into Children, flips Loaded, and re-renders — showing a + /// spinner in flight and an error row on failure. A leaf (Tag) has no expander so never + /// reaches here. + /// + private async Task ToggleAsync(RawNode node) + { + if (node.Loading) { return; } + + node.Expanded = !node.Expanded; + + if (node.Expanded && node.HasLazyChildren && !node.Loaded) + { + node.Loading = true; + node.Error = null; + StateHasChanged(); + try + { + var children = await Svc.LoadChildrenAsync(node); + node.Children.Clear(); + node.Children.AddRange(children); + node.ChildCount = node.Children.Count; + node.Loaded = true; + } + catch (Exception ex) + { + node.Error = ex.Message; + } + finally + { + node.Loading = false; + } + StateHasChanged(); + } + } + + // --------------------------------------------------------------------------- + // Per-kind context menus. Every OnClick opens a placeholder in this wave; the + // named handlers below are the seam Wave B/C replaces with the real modals. + // --------------------------------------------------------------------------- + + private IReadOnlyList MenuFor(RawNode node) => node.Kind switch + { + RawNodeKind.Cluster => ClusterMenu(node), + RawNodeKind.Folder => FolderMenu(node), + RawNodeKind.Driver => DriverMenu(node), + RawNodeKind.Device => DeviceMenu(node), + RawNodeKind.TagGroup => TagGroupMenu(node), + RawNodeKind.Tag => TagMenu(node), + _ => Array.Empty(), // Enterprise: label only, no menu. + }; + + private List ClusterMenu(RawNode node) => new() + { + new() { Label = "New folder", Icon = "📁", OnClick = () => OnNewFolder(node) }, + new() { Label = "New driver", Icon = "🔌", OnClick = () => OnNewDriver(node) }, + }; + + private List FolderMenu(RawNode node) => new() + { + new() { Label = "New folder", Icon = "📁", OnClick = () => OnNewFolder(node) }, + new() { Label = "New driver", Icon = "🔌", OnClick = () => OnNewDriver(node) }, + ContextMenuItem.Separator(), + new() { Label = "Rename", Icon = "✏️", OnClick = () => OnRenameFolder(node) }, + new() { Label = "Delete", Icon = "🗑️", OnClick = () => OnDeleteFolder(node) }, + }; + + private List DriverMenu(RawNode node) => new() + { + new() { Label = "Configure", Icon = "⚙", OnClick = () => OnConfigureDriver(node) }, + new() { Label = "Test connect", Icon = "🔎", OnClick = () => OnTestConnect(node) }, + new() { Label = "New device", Icon = "📟", OnClick = () => OnNewDevice(node) }, + ContextMenuItem.Separator(), + new() { Label = node.Enabled ? "Disable" : "Enable", Icon = node.Enabled ? "⏸" : "▶", + OnClick = () => OnToggleDriverEnabled(node) }, + new() { Label = "Rename", Icon = "✏️", OnClick = () => OnRenameDriver(node) }, + new() { Label = "Delete", Icon = "🗑️", OnClick = () => OnDeleteDriver(node) }, + }; + + private List DeviceMenu(RawNode node) => new() + { + new() { Label = "Configure", Icon = "⚙", OnClick = () => OnConfigureDevice(node) }, + new() { Label = "New tag-group", Icon = "🗂️", OnClick = () => OnNewTagGroup(node) }, + ContextMenuItem.Separator(), + // "Add tags ▸" flattened — ContextMenu is single-level, so the submenu is a labelled group. + new() { Label = "Add tags", Disabled = true }, + new() { Label = "Manual entry", Icon = "✍️", OnClick = () => OnAddManualTags(node) }, + new() { Label = "Import CSV", Icon = "📥", OnClick = () => OnImportCsv(node) }, + new() { Label = "Export CSV", Icon = "📤", OnClick = () => OnExportCsv(node) }, + new() { Label = "Browse device…", Icon = "🔭", OnClick = () => OnBrowseDevice(node) }, + ContextMenuItem.Separator(), + new() { Label = "Delete", Icon = "🗑️", OnClick = () => OnDeleteDevice(node) }, + }; + + private List TagGroupMenu(RawNode node) => new() + { + new() { Label = "New group", Icon = "🗂️", OnClick = () => OnNewGroup(node) }, + ContextMenuItem.Separator(), + new() { Label = "Add tags", Disabled = true }, + new() { Label = "Manual entry", Icon = "✍️", OnClick = () => OnAddManualTags(node) }, + new() { Label = "Import CSV", Icon = "📥", OnClick = () => OnImportCsv(node) }, + new() { Label = "Export CSV", Icon = "📤", OnClick = () => OnExportCsv(node) }, + new() { Label = "Browse device…", Icon = "🔭", OnClick = () => OnBrowseDevice(node) }, + ContextMenuItem.Separator(), + new() { Label = "Rename", Icon = "✏️", OnClick = () => OnRenameTagGroup(node) }, + new() { Label = "Delete", Icon = "🗑️", OnClick = () => OnDeleteTagGroup(node) }, + }; + + private List TagMenu(RawNode node) => new() + { + new() { Label = "Edit", Icon = "✏️", OnClick = () => OnEditTag(node) }, + new() { Label = "Delete", Icon = "🗑️", OnClick = () => OnDeleteTag(node) }, + }; + + // --------------------------------------------------------------------------- + // Named action handlers — the Wave B/C replacement seam. Each opens a placeholder + // today; a later wave swaps the body for the real modal invocation. + // --------------------------------------------------------------------------- + + // Folder / Cluster + private Task OnNewFolder(RawNode node) => ShowStub("New folder", node); + private Task OnNewDriver(RawNode node) => ShowStub("New driver", node); + private Task OnRenameFolder(RawNode node) => ShowStub("Rename folder", node); + private Task OnDeleteFolder(RawNode node) => ShowStub("Delete folder", node); + + // Driver + private Task OnConfigureDriver(RawNode node) => ShowStub("Configure driver", node); + private Task OnTestConnect(RawNode node) => ShowStub("Test connect", node); + private Task OnNewDevice(RawNode node) => ShowStub("New device", node); + private Task OnToggleDriverEnabled(RawNode node) => ShowStub(node.Enabled ? "Disable driver" : "Enable driver", node); + private Task OnRenameDriver(RawNode node) => ShowStub("Rename driver", node); + private Task OnDeleteDriver(RawNode node) => ShowStub("Delete driver", node); + + // Device + private Task OnConfigureDevice(RawNode node) => ShowStub("Configure device", node); + private Task OnNewTagGroup(RawNode node) => ShowStub("New tag-group", node); + private Task OnDeleteDevice(RawNode node) => ShowStub("Delete device", node); + + // Add-tags submenu (shared by Device + TagGroup) + private Task OnAddManualTags(RawNode node) => ShowStub("Add tags — manual entry", node); + private Task OnImportCsv(RawNode node) => ShowStub("Import tags CSV", node); + private Task OnExportCsv(RawNode node) => ShowStub("Export tags CSV", node); + private Task OnBrowseDevice(RawNode node) => ShowStub("Browse device", node); + + // TagGroup + private Task OnNewGroup(RawNode node) => ShowStub("New group", node); + private Task OnRenameTagGroup(RawNode node) => ShowStub("Rename tag-group", node); + private Task OnDeleteTagGroup(RawNode node) => ShowStub("Delete tag-group", node); + + // Tag + private Task OnEditTag(RawNode node) => ShowStub("Edit tag", node); + private Task OnDeleteTag(RawNode node) => ShowStub("Delete tag", node); + + // --------------------------------------------------------------------------- + // Placeholder plumbing (removed once every handler above has its real modal). + // --------------------------------------------------------------------------- + + private Task ShowStub(string action, RawNode node) + { + _stubTitle = action; + _stubMessage = $"{action} — coming in a later wave."; + _stubNodeName = node.DisplayName; + _stubVisible = true; + StateHasChanged(); + return Task.CompletedTask; + } + + private void CloseStub() + { + _stubVisible = false; + _stubTitle = ""; + _stubMessage = ""; + _stubNodeName = null; + } +}