feat(central-ui): InstanceTreeBuilder — area/instance forest for the instance picker

Task 1 of the Instance Picker plan (docs/plans/2026-09-08-instance-picker.md).
Pure, DI-free builder that nests a site's areas by ParentAreaId and hangs each
area's instances beneath it, mirroring Topology's ordering (child areas first,
then that level's instances; OrdinalIgnoreCase sorts) with unparented instances
at the root after the root areas. Orphaned areas become roots, a case-insensitive
filter keeps matching instances plus their ancestors and drops empty branches,
and a ParentAreaId cycle can never recurse forever.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
Joseph Doherty
2026-09-08 09:22:11 -04:00
parent 356b7620f8
commit f12ba2f9eb
3 changed files with 248 additions and 0 deletions
@@ -0,0 +1,82 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
/// <summary>
/// Builds the Instance Picker's area/instance forest from a site's flat area and
/// instance lists. Pure — no Blazor, no DI, no I/O — so the shape is unit-testable
/// on its own and the picker component only has to render it.
/// </summary>
public static class InstanceTreeBuilder
{
/// <summary>
/// Nests the supplied areas by <see cref="Area.ParentAreaId"/> and hangs each area's
/// instances beneath it, mirroring the Topology page's ordering: at every level the
/// child areas come first (sorted by name, ordinal-ignore-case), then that level's
/// instances (sorted by unique name, ordinal-ignore-case). Instances with no area sit
/// at the root after the root areas. An area whose parent id points at an area that is
/// not in <paramref name="areas"/> is treated as a root, so a stale row can never hide
/// a subtree.
/// </summary>
/// <param name="areas">The site's areas (flat, linked by <see cref="Area.ParentAreaId"/>); null is treated as empty.</param>
/// <param name="instances">The site's instances; null is treated as empty.</param>
/// <param name="filter">
/// Optional case-insensitive substring matched against <see cref="Instance.UniqueName"/>.
/// When set, only matching instances are kept along with their ancestor areas; areas left
/// with nothing beneath them are dropped. Null or whitespace means no filter.
/// </param>
/// <returns>The root rows of the forest, never null.</returns>
public static IReadOnlyList<InstanceTreeNode> Build(
IReadOnlyList<Area>? areas,
IReadOnlyList<Instance>? instances,
string? filter)
{
areas ??= [];
instances ??= [];
var needle = filter?.Trim();
var hasFilter = !string.IsNullOrEmpty(needle);
var knownAreaIds = areas.Select(a => a.Id).ToHashSet();
// Child areas by effective parent id — null for a root or an orphaned area.
var areasByParent = areas
.OrderBy(a => a.Name, StringComparer.OrdinalIgnoreCase)
.ToLookup(a => a.ParentAreaId is int p && knownAreaIds.Contains(p) ? p : (int?)null);
var instancesByArea = instances
.Where(i => !hasFilter || i.UniqueName.Contains(needle!, StringComparison.OrdinalIgnoreCase))
.OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase)
.ToLookup(i => i.AreaId);
return BuildLevel(parentId: null, areasByParent, instancesByArea, hasFilter, ancestors: []);
}
private static List<InstanceTreeNode> BuildLevel(
int? parentId,
ILookup<int?, Area> areasByParent,
ILookup<int?, Instance> instancesByArea,
bool hasFilter,
HashSet<int> ancestors)
{
var level = new List<InstanceTreeNode>();
foreach (var area in areasByParent[parentId])
{
// Defensive: a cycle in ParentAreaId must never recurse forever.
if (!ancestors.Add(area.Id)) continue;
var node = new InstanceTreeNode($"a:{area.Id}", area.Name, area, Instance: null);
node.Children.AddRange(BuildLevel(area.Id, areasByParent, instancesByArea, hasFilter, ancestors));
ancestors.Remove(area.Id);
// With a filter an area survives only if something matched beneath it.
if (!hasFilter || node.HasChildren) level.Add(node);
}
level.AddRange(instancesByArea[parentId].Select(i =>
new InstanceTreeNode($"i:{i.Id}", i.UniqueName, Area: null, Instance: i)));
return level;
}
}
@@ -0,0 +1,23 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
/// <summary>
/// One row of the Instance Picker tree: an area branch or an instance leaf.
/// Keys are <c>a:{areaId}</c> for areas and <c>i:{instanceId}</c> for instances.
/// </summary>
/// <param name="Key">Stable tree key — <c>a:{areaId}</c> or <c>i:{instanceId}</c>.</param>
/// <param name="Label">Text rendered for this row.</param>
/// <param name="Area">The area this node represents, or null for an instance leaf.</param>
/// <param name="Instance">The instance this node represents, or null for an area branch.</param>
public sealed record InstanceTreeNode(string Key, string Label, Area? Area, Instance? Instance)
{
/// <summary>Gets the child rows nested under this node (child areas first, then instances).</summary>
public List<InstanceTreeNode> Children { get; } = new();
/// <summary>Gets a value indicating whether this node is a selectable instance leaf.</summary>
public bool IsInstance => Instance is not null;
/// <summary>Gets a value indicating whether this node has any child rows.</summary>
public bool HasChildren => Children.Count > 0;
}