feat(adminui): wire Galaxy live-browse picker into the standard TagModal

This commit is contained in:
Joseph Doherty
2026-06-12 22:09:22 -04:00
parent 056bfbda1b
commit 0945f19a50
5 changed files with 115 additions and 18 deletions
@@ -298,7 +298,7 @@ else
private bool _tagModalVisible;
private bool _tagModalIsNew;
private TagEditDto? _tagModalExisting;
private IReadOnlyList<(string Id, string Display, string DriverType)> _tagDriverOptions = Array.Empty<(string, string, string)>();
private IReadOnlyList<(string Id, string Display, string DriverType, string DriverConfig)> _tagDriverOptions = Array.Empty<(string, string, string, string)>();
// --- Virtual Tags tab state. _vtags is null until the tab is first activated. ---
private IReadOnlyList<EquipmentVirtualTagRow>? _vtags;
@@ -5,9 +5,12 @@
either, the owning equipment is fixed. On a successful save it raises OnSaved so the host can
refresh the equipment's children in place. *@
@using System.ComponentModel.DataAnnotations
@using System.Text.Json
@using Microsoft.AspNetCore.Components.Forms
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
@inject IUnsTreeService Svc
@@ -44,9 +47,9 @@
<label class="form-label" for="tag-driver">Driver instance</label>
<InputSelect id="tag-driver" @bind-Value="_form.DriverInstanceId" @bind-Value:after="OnDriverChanged" class="form-select form-select-sm">
<option value="">— pick a driver —</option>
@foreach (var (id, display, _) in Drivers)
@foreach (var d in Drivers)
{
<option value="@id">@display</option>
<option value="@d.Id">@d.Display</option>
}
</InputSelect>
<ValidationMessage For="@(() => _form.DriverInstanceId)" />
@@ -90,6 +93,35 @@
{
<div class="form-text">Pick a driver above to configure this tag.</div>
}
else if (IsGalaxyDriver)
{
@* GalaxyMxGateway has no typed TagConfigEditorMap editor; instead a Galaxy point is
authored as {"FullName":"tag_name.AttributeName"}. Offer the live-browse picker
(against the selected gateway's DriverConfig) plus a manual raw-JSON fallback. *@
<button type="button" class="btn btn-outline-secondary btn-sm mb-2"
@onclick="@(() => _showGalaxyPicker = true)">
Browse Galaxy
</button>
<InputTextArea id="tag-config" @bind-Value="_form.TagConfig" rows="3"
class="form-control form-control-sm mono"
placeholder='{ "FullName": "DelmiaReceiver_001.DownloadPath" }' />
<div class="form-text">
The Galaxy reference, stored as <code>{"FullName":"tag_name.AttributeName"}</code>.
Pick one via <strong>Browse Galaxy</strong> or edit the JSON directly.
</div>
@if (_showGalaxyPicker)
{
<DriverTagPicker @bind-Visible="_showGalaxyPicker"
Title="Galaxy address"
CurrentAddress="@_galaxyAddress"
OnPickAddress="@OnGalaxyAddressPicked">
<GalaxyAddressPickerBody CurrentAddress="@_galaxyAddress"
CurrentAddressChanged="@((s) => _galaxyAddress = s)"
GetConfigJson="@(() => SelectedDriverConfig)" />
</DriverTagPicker>
}
}
else if (editorType is not null)
{
<DynamicComponent Type="editorType" Parameters="BuildEditorParameters()" />
@@ -139,8 +171,10 @@
/// <summary>The tag being edited, when <see cref="IsNew"/> is <c>false</c>.</summary>
[Parameter] public TagEditDto? Existing { get; set; }
/// <summary>The candidate drivers — scoped to the equipment's cluster by the host — as <c>(Id, Display)</c> pairs.</summary>
[Parameter] public IReadOnlyList<(string Id, string Display, string DriverType)> Drivers { get; set; } = Array.Empty<(string, string, string)>();
/// <summary>The candidate drivers — scoped to the equipment's cluster by the host — as
/// <c>(Id, Display, DriverType, DriverConfig)</c> tuples. <c>DriverType</c> drives typed-editor dispatch;
/// <c>DriverConfig</c> feeds the Galaxy live-browse picker for GalaxyMxGateway drivers.</summary>
[Parameter] public IReadOnlyList<(string Id, string Display, string DriverType, string DriverConfig)> Drivers { get; set; } = Array.Empty<(string, string, string, string)>();
/// <summary>Raised after a successful create/save so the host can refresh the equipment's children and close.</summary>
[Parameter] public EventCallback OnSaved { get; set; }
@@ -152,14 +186,47 @@
private bool _busy;
private string? _error;
// Galaxy live-browse picker state. Only meaningful when the selected driver is a GalaxyMxGateway.
private bool _showGalaxyPicker;
private string _galaxyAddress = "";
// The DriverType of the currently-selected driver (drives editor dispatch). Null when no driver chosen.
private string? SelectedDriverType =>
Drivers.FirstOrDefault(d => d.Id == _form.DriverInstanceId).DriverType;
// The DriverConfig JSON of the currently-selected driver — fed to the Galaxy picker so it browses the
// right gateway. Defaults to "{}" when no driver is chosen or the config is empty.
private string SelectedDriverConfig
{
get
{
var cfg = Drivers.FirstOrDefault(d => d.Id == _form.DriverInstanceId).DriverConfig;
return string.IsNullOrEmpty(cfg) ? "{}" : cfg;
}
}
// True when the selected driver is a GalaxyMxGateway — Galaxy points are authored as
// {"FullName":"tag_name.AttributeName"} via the live-browse picker rather than a typed editor.
private bool IsGalaxyDriver => SelectedDriverType == "GalaxyMxGateway";
// When the operator switches drivers, the previous driver's TagConfig schema no longer applies —
// reset it so the newly-dispatched typed editor starts clean (no stale/leaked keys from the old
// driver). Fires only on a user dropdown change (@bind-Value:after), not on the initial edit-load.
private void OnDriverChanged() => _form.TagConfig = "{}";
private void OnDriverChanged()
{
_form.TagConfig = "{}";
// The Galaxy reference belongs to the previous driver; clear the picker's working address too.
_galaxyAddress = "";
}
// The operator picked a Galaxy reference (tag_name.AttributeName); store it as the canonical
// {"FullName":"..."} TagConfig the Galaxy driver resolves to an MXAccess reference. Default
// (PascalCase) serialization yields the "FullName" key the driver/walker reads.
private void OnGalaxyAddressPicked(string address)
{
_galaxyAddress = address;
_form.TagConfig = JsonSerializer.Serialize(new { FullName = address });
}
private IDictionary<string, object> BuildEditorParameters() => new Dictionary<string, object>
{
@@ -189,6 +256,28 @@
};
}
_error = null;
_showGalaxyPicker = false;
// Seed the picker's working address from any existing {"FullName":"..."} so it opens pre-populated.
_galaxyAddress = ReadFullName(_form.TagConfig);
}
// Best-effort extraction of FullName from a Galaxy TagConfig; returns "" when absent or unparseable.
private static string ReadFullName(string? configJson)
{
if (string.IsNullOrWhiteSpace(configJson)) return "";
try
{
using var doc = JsonDocument.Parse(configJson);
return doc.RootElement.ValueKind == JsonValueKind.Object
&& doc.RootElement.TryGetProperty("FullName", out var fn)
&& fn.ValueKind == JsonValueKind.String
? fn.GetString() ?? ""
: "";
}
catch (JsonException)
{
return "";
}
}
private async Task SaveAsync()
@@ -340,9 +340,11 @@ public interface IUnsTreeService
/// </summary>
/// <param name="equipmentId">The equipment whose candidate drivers to load.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The eligible drivers projected to <c>(DriverInstanceId, Display, DriverType)</c> triples,
/// where <c>DriverType</c> lets the TagModal dispatch to a per-driver-type typed config editor.</returns>
Task<IReadOnlyList<(string DriverInstanceId, string Display, string DriverType)>> LoadTagDriversForEquipmentAsync(string equipmentId, CancellationToken ct = default);
/// <returns>The eligible drivers projected to <c>(DriverInstanceId, Display, DriverType, DriverConfig)</c>
/// tuples, where <c>DriverType</c> lets the TagModal dispatch to a per-driver-type typed config editor
/// and <c>DriverConfig</c> feeds the live-browse address picker (e.g. the Galaxy gateway picker opens its
/// browse session against the selected GalaxyMxGateway's config).</returns>
Task<IReadOnlyList<(string DriverInstanceId, string Display, string DriverType, string DriverConfig)>> LoadTagDriversForEquipmentAsync(string equipmentId, CancellationToken ct = default);
/// <summary>
/// Creates a new equipment-bound tag. <c>FolderPath</c> is always <c>null</c> (decision #110 —
@@ -711,7 +711,7 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
}
/// <inheritdoc />
public async Task<IReadOnlyList<(string DriverInstanceId, string Display, string DriverType)>> LoadTagDriversForEquipmentAsync(
public async Task<IReadOnlyList<(string DriverInstanceId, string Display, string DriverType, string DriverConfig)>> LoadTagDriversForEquipmentAsync(
string equipmentId,
CancellationToken ct = default)
{
@@ -720,10 +720,12 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
var equipmentCluster = await ResolveEquipmentClusterAsync(db, equipmentId, ct);
if (equipmentCluster is null)
{
return Array.Empty<(string, string, string)>();
return Array.Empty<(string, string, string, string)>();
}
// Drivers in the equipment's cluster whose namespace is Equipment-kind (decision #110).
// GalaxyMxGateway is an ordinary Equipment-kind driver post-de-split, so it surfaces here
// alongside the PLC drivers; its DriverConfig feeds the Galaxy live-browse address picker.
var equipmentNamespaceIds = await db.Namespaces
.Where(n => n.ClusterId == equipmentCluster && n.Kind == NamespaceKind.Equipment)
.Select(n => n.NamespaceId)
@@ -732,11 +734,11 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
var drivers = await db.DriverInstances
.Where(d => d.ClusterId == equipmentCluster && equipmentNamespaceIds.Contains(d.NamespaceId))
.OrderBy(d => d.DriverInstanceId)
.Select(d => new { d.DriverInstanceId, d.Name, d.DriverType })
.Select(d => new { d.DriverInstanceId, d.Name, d.DriverType, d.DriverConfig })
.ToListAsync(ct);
return drivers
.Select(d => (d.DriverInstanceId, Display: $"{d.DriverInstanceId} — {d.Name}", d.DriverType))
.Select(d => (d.DriverInstanceId, Display: $"{d.DriverInstanceId} — {d.Name}", d.DriverType, d.DriverConfig))
.ToList();
}
@@ -8,17 +8,19 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// Verifies that <see cref="UnsTreeService.LoadTagDriversForEquipmentAsync"/> surfaces each
/// candidate driver's <c>DriverType</c> alongside its id and display string, so the UNS TagModal
/// can later dispatch to a per-driver-type typed tag-config editor (F-uns-1).
/// candidate driver's <c>DriverType</c> and <c>DriverConfig</c> alongside its id and display string,
/// so the UNS TagModal can dispatch to a per-driver-type typed tag-config editor (F-uns-1) and feed
/// the selected gateway's config to the Galaxy live-browse address picker.
/// </summary>
[Trait("Category", "Unit")]
public sealed class UnsTreeServiceTagDriversTests
{
/// <summary>
/// A driver loaded for an equipment carries its <c>DriverType</c> in the returned tuple.
/// A driver loaded for an equipment carries its <c>DriverType</c> and <c>DriverConfig</c> in the
/// returned tuple.
/// </summary>
[Fact]
public async Task LoadTagDriversForEquipment_surfaces_driver_type()
public async Task LoadTagDriversForEquipment_surfaces_driver_type_and_config()
{
var dbName = $"uns-tagdrivers-{Guid.NewGuid():N}";
@@ -57,7 +59,7 @@ public sealed class UnsTreeServiceTagDriversTests
NamespaceId = "NS-EQ",
Name = "equipment driver",
DriverType = "ModbusTcp",
DriverConfig = "{}",
DriverConfig = """{"endpoint":"10.0.0.1:502"}""",
});
db.SaveChanges();
}
@@ -69,5 +71,7 @@ public sealed class UnsTreeServiceTagDriversTests
drivers.Count.ShouldBe(1);
drivers[0].DriverInstanceId.ShouldBe("DRV-EQ");
drivers[0].DriverType.ShouldBe("ModbusTcp");
// The picker (Galaxy live-browse) opens its session against the selected driver's config.
drivers[0].DriverConfig.ShouldBe("""{"endpoint":"10.0.0.1:502"}""");
}
}