feat(centralui): protocol selector + MxGateway editor in DataConnectionForm

Adds an OPC UA | MxGateway protocol dropdown (create-time; locked read-only on
edit), branches the primary/backup endpoint editors, serializer, and validator
by protocol, and persists DataConnection.Protocol accordingly. Updates form
tests: protocol dropdown present on create + MxGateway save round-trips typed
JSON with Protocol=MxGateway.
This commit is contained in:
Joseph Doherty
2026-05-29 08:02:44 -04:00
parent 648d00692f
commit be32e4a7ff
2 changed files with 162 additions and 35 deletions
@@ -49,17 +49,45 @@
</select>
}
</div>
<div class="mb-2">
<label class="form-label small">Protocol</label>
@if (_protocolLocked)
{
<input type="text"
class="form-control form-control-plaintext form-control-sm"
readonly
value="@_protocol" />
<div class="form-text">Protocol is locked after creation.</div>
}
else
{
<select class="form-select form-select-sm" @bind="_protocol">
<option value="OpcUa">OPC UA</option>
<option value="MxGateway">MxGateway</option>
</select>
}
</div>
<div class="mb-3">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_formName" />
</div>
<h6 class="text-muted mt-3">Primary endpoint</h6>
<OpcUaEndpointEditor Title="Primary Endpoint"
IdPrefix="primary"
Config="_primaryConfig"
IsLegacy="_primaryIsLegacy"
Errors="_primaryErrors" />
@if (_protocol == "MxGateway")
{
<MxGatewayEndpointEditor Title="Primary Endpoint"
IdPrefix="primary"
Config="_primaryMx"
Errors="_primaryErrors" />
}
else
{
<OpcUaEndpointEditor Title="Primary Endpoint"
IdPrefix="primary"
Config="_primaryConfig"
IsLegacy="_primaryIsLegacy"
Errors="_primaryErrors" />
}
<h6 class="text-muted mt-3">
Backup endpoint
@@ -77,11 +105,21 @@
}
else
{
<OpcUaEndpointEditor Title="Backup Endpoint"
IdPrefix="backup"
Config="_backupConfig"
IsLegacy="_backupIsLegacy"
Errors="_backupErrors" />
@if (_protocol == "MxGateway")
{
<MxGatewayEndpointEditor Title="Backup Endpoint"
IdPrefix="backup"
Config="_backupMx"
Errors="_backupErrors" />
}
else
{
<OpcUaEndpointEditor Title="Backup Endpoint"
IdPrefix="backup"
Config="_backupConfig"
IsLegacy="_backupIsLegacy"
Errors="_backupErrors" />
}
<div class="mb-2">
<label class="form-label small">Failover Retry Count</label>
<input type="number" class="form-control form-control-sm" style="max-width: 120px;"
@@ -118,8 +156,12 @@
private string _siteName = string.Empty;
private bool _siteLocked;
private string _formName = string.Empty;
private string _protocol = "OpcUa";
private bool _protocolLocked;
private OpcUaEndpointConfig _primaryConfig = new();
private OpcUaEndpointConfig _backupConfig = new();
private MxGatewayEndpointConfig _primaryMx = new();
private MxGatewayEndpointConfig _backupMx = new();
private bool _primaryIsLegacy;
private bool _backupIsLegacy;
private bool _showBackup;
@@ -143,17 +185,10 @@
_siteName = _sites.FirstOrDefault(s => s.Id == _formSiteId)?.Name ?? $"Site {_formSiteId}";
_siteLocked = true;
_formName = _editingConnection.Name;
_protocol = string.IsNullOrWhiteSpace(_editingConnection.Protocol) ? "OpcUa" : _editingConnection.Protocol;
_protocolLocked = true;
(_primaryConfig, _primaryIsLegacy) =
OpcUaEndpointConfigSerializer.Deserialize(_editingConnection.PrimaryConfiguration);
if (!string.IsNullOrWhiteSpace(_editingConnection.BackupConfiguration))
{
(_backupConfig, _backupIsLegacy) =
OpcUaEndpointConfigSerializer.Deserialize(_editingConnection.BackupConfiguration);
_showBackup = true;
_formFailoverRetryCount = _editingConnection.FailoverRetryCount;
}
LoadConfig(_editingConnection);
}
}
else if (SiteId.HasValue)
@@ -177,32 +212,80 @@
}
}
private void LoadConfig(DataConnection conn)
{
if (_protocol == "MxGateway")
{
_primaryMx = MxGatewayEndpointConfigSerializer.Deserialize(conn.PrimaryConfiguration);
if (!string.IsNullOrWhiteSpace(conn.BackupConfiguration))
{
_backupMx = MxGatewayEndpointConfigSerializer.Deserialize(conn.BackupConfiguration);
_showBackup = true;
_formFailoverRetryCount = conn.FailoverRetryCount;
}
}
else
{
(_primaryConfig, _primaryIsLegacy) =
OpcUaEndpointConfigSerializer.Deserialize(conn.PrimaryConfiguration);
if (!string.IsNullOrWhiteSpace(conn.BackupConfiguration))
{
(_backupConfig, _backupIsLegacy) =
OpcUaEndpointConfigSerializer.Deserialize(conn.BackupConfiguration);
_showBackup = true;
_formFailoverRetryCount = conn.FailoverRetryCount;
}
}
}
private async Task SaveConnection()
{
_formError = null;
if (_formSiteId == 0) { _formError = "Site is required."; return; }
if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; }
_primaryErrors = OpcUaEndpointConfigValidator.Validate(_primaryConfig, "Primary.");
_backupErrors = _showBackup
? OpcUaEndpointConfigValidator.Validate(_backupConfig, "Backup.")
: null;
string primaryJson;
string? backupJson;
if (!_primaryErrors.IsValid || (_backupErrors is { IsValid: false }))
if (_protocol == "MxGateway")
{
_formError = "Fix the errors below before saving.";
return;
}
_primaryErrors = MxGatewayEndpointConfigValidator.Validate(_primaryMx, "Primary.");
_backupErrors = _showBackup
? MxGatewayEndpointConfigValidator.Validate(_backupMx, "Backup.")
: null;
var primaryJson = OpcUaEndpointConfigSerializer.Serialize(_primaryConfig);
var backupJson = _showBackup ? OpcUaEndpointConfigSerializer.Serialize(_backupConfig) : null;
if (!_primaryErrors.IsValid || (_backupErrors is { IsValid: false }))
{
_formError = "Fix the errors below before saving.";
return;
}
primaryJson = MxGatewayEndpointConfigSerializer.Serialize(_primaryMx);
backupJson = _showBackup ? MxGatewayEndpointConfigSerializer.Serialize(_backupMx) : null;
}
else
{
_primaryErrors = OpcUaEndpointConfigValidator.Validate(_primaryConfig, "Primary.");
_backupErrors = _showBackup
? OpcUaEndpointConfigValidator.Validate(_backupConfig, "Backup.")
: null;
if (!_primaryErrors.IsValid || (_backupErrors is { IsValid: false }))
{
_formError = "Fix the errors below before saving.";
return;
}
primaryJson = OpcUaEndpointConfigSerializer.Serialize(_primaryConfig);
backupJson = _showBackup ? OpcUaEndpointConfigSerializer.Serialize(_backupConfig) : null;
}
try
{
if (_editingConnection != null)
{
_editingConnection.Name = _formName.Trim();
_editingConnection.Protocol = "OpcUa";
_editingConnection.Protocol = _protocol;
_editingConnection.PrimaryConfiguration = primaryJson;
_editingConnection.BackupConfiguration = backupJson;
_editingConnection.FailoverRetryCount = _showBackup ? _formFailoverRetryCount : 3;
@@ -210,7 +293,7 @@
}
else
{
var conn = new DataConnection(_formName.Trim(), "OpcUa", _formSiteId)
var conn = new DataConnection(_formName.Trim(), _protocol, _formSiteId)
{
PrimaryConfiguration = primaryJson,
BackupConfiguration = backupJson,
@@ -233,6 +316,7 @@
{
_showBackup = false;
_backupConfig = new OpcUaEndpointConfig();
_backupMx = new MxGatewayEndpointConfig();
_backupIsLegacy = false;
_formFailoverRetryCount = 3;
}
@@ -44,12 +44,55 @@ public class DataConnectionFormTests : BunitContext
}
[Fact]
public void NoProtocolDropdown_IsRendered()
public void ProtocolDropdown_IsRendered_OnCreate_WithBothProtocols()
{
var cut = RenderForCreateSite(1);
Assert.DoesNotContain("Custom", cut.Markup);
var labels = cut.FindAll("label").Select(l => l.TextContent.Trim()).ToList();
Assert.DoesNotContain(labels, l => l == "Protocol");
Assert.Contains(labels, l => l == "Protocol");
// The protocol select offers OPC UA and MxGateway.
var optionTexts = cut.FindAll("option").Select(o => o.TextContent.Trim()).ToList();
Assert.Contains("OPC UA", optionTexts);
Assert.Contains("MxGateway", optionTexts);
}
[Fact]
public async Task Save_MxGateway_PersistsTypedJsonAndProtocolMxGateway()
{
DataConnection? captured = null;
await _siteRepo.AddDataConnectionAsync(
Arg.Do<DataConnection>(d => captured = d));
var cut = RenderForCreateSite(1);
// Switch protocol to MxGateway — re-renders with the MxGateway editor.
cut.FindAll("select")
.First(s => s.QuerySelectorAll("option").Any(o => o.TextContent.Trim() == "MxGateway"))
.Change("MxGateway");
// Name (skip readonly Site plaintext input; MxGateway editor inputs carry placeholders).
cut.FindAll("input[type='text']")
.First(i => !i.HasAttribute("readonly") && i.GetAttribute("placeholder") is null)
.Change("MX-1");
// Gateway endpoint
cut.FindAll("input[type='text']")
.First(i => i.GetAttribute("placeholder")?.StartsWith("http://") == true)
.Change("http://gw:5000");
// API key (password input)
cut.FindAll("input[type='password']")
.First(i => i.GetAttribute("placeholder")?.Contains("API key") == true)
.Change("secret");
await cut.FindAll("button")
.First(b => b.TextContent.Trim() == "Save").ClickAsync(new());
Assert.NotNull(captured);
Assert.Equal("MxGateway", captured!.Protocol);
Assert.NotNull(captured.PrimaryConfiguration);
using var doc = JsonDocument.Parse(captured.PrimaryConfiguration!);
Assert.Equal("http://gw:5000",
doc.RootElement.GetProperty("endpoint").GetString());
}
[Fact]