- }
+
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/AbCipAddressPickerBody.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/AbCipAddressPickerBody.razor
index 1f6cede61..eea3c3416 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/AbCipAddressPickerBody.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/AbCipAddressPickerBody.razor
@@ -57,7 +57,7 @@
}
- @if (_openError is not null) { @TruncatedError() }
+ @if (_openError is not null) { }
@if (_token != Guid.Empty)
@@ -154,8 +154,6 @@
return _useIndex ? $"{_tagName}[{_elementIndex}]" : _tagName;
}
- private string TruncatedError() =>
- _openError is null ? "" : (_openError.Length > 60 ? _openError[..60] + "…" : _openError);
public ValueTask DisposeAsync()
{
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/FOCASAddressPickerBody.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/FOCASAddressPickerBody.razor
index bfc641f4b..012243f61 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/FOCASAddressPickerBody.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/FOCASAddressPickerBody.razor
@@ -121,7 +121,7 @@
}
- @if (_openError is not null) { @TruncatedError() }
+ @if (_openError is not null) { }
@if (_token != Guid.Empty)
@@ -228,8 +228,6 @@
? FocasAddressBuilder.BuildAlarm(_alarmScope, _alarmLow, _alarmHigh)
: FocasAddressBuilder.Build(_kind, _pmcLetter, _number, _bitIndex);
- private string TruncatedError() =>
- _openError is null ? "" : (_openError.Length > 60 ? _openError[..60] + "…" : _openError);
public ValueTask DisposeAsync()
{
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/GalaxyAddressPickerBody.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/GalaxyAddressPickerBody.razor
index 14fedd8ca..8ae418d34 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/GalaxyAddressPickerBody.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/GalaxyAddressPickerBody.razor
@@ -42,7 +42,7 @@
Browser open
}
- @if (_openError is not null) { @TruncatedError() }
+ @if (_openError is not null) { }
@if (_token != Guid.Empty)
@@ -215,8 +215,6 @@
return $"{_tagName}.{_attributeName}";
}
- private string TruncatedError() =>
- _openError is null ? "" : (_openError.Length > 60 ? _openError[..60] + "…" : _openError);
public ValueTask DisposeAsync()
{
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/OpcUaClientAddressPickerBody.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/OpcUaClientAddressPickerBody.razor
index 02b79537b..823d9f864 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/OpcUaClientAddressPickerBody.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/OpcUaClientAddressPickerBody.razor
@@ -40,7 +40,7 @@
Close
}
- @if (_openError is not null) { @TruncatedError() }
+ @if (_openError is not null) { }
@if (_token != Guid.Empty)
@@ -113,8 +113,6 @@
await CurrentAddressChanged.InvokeAsync(_built);
}
- private string TruncatedError() =>
- _openError is null ? "" : (_openError.Length > 60 ? _openError[..60] + "…" : _openError);
public ValueTask DisposeAsync()
{
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/SqlAddressPickerBody.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/SqlAddressPickerBody.razor
index 61b445f14..43264f4e1 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/SqlAddressPickerBody.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/SqlAddressPickerBody.razor
@@ -156,7 +156,7 @@ else
}
@* Same open-error chip the five sibling pickers use: abbreviated inline, full text on title. *@
- @if (_openError is not null) { @DisplayText.Abbreviate(_openError, 60) }
+ @if (_openError is not null) { }
@if (_token != Guid.Empty)
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/TwinCATAddressPickerBody.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/TwinCATAddressPickerBody.razor
index f5cab6a2d..50a1d1589 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/TwinCATAddressPickerBody.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/TwinCATAddressPickerBody.razor
@@ -46,7 +46,7 @@
}
- @if (_openError is not null) { @TruncatedError() }
+ @if (_openError is not null) { }
@if (_token != Guid.Empty)
@@ -133,8 +133,6 @@
await CurrentAddressChanged.InvokeAsync(_built);
}
- private string TruncatedError() =>
- _openError is null ? "" : (_openError.Length > 60 ? _openError[..60] + "…" : _openError);
public ValueTask DisposeAsync()
{
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/RefusalChip.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/RefusalChip.razor
new file mode 100644
index 000000000..b0044f22e
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/RefusalChip.razor
@@ -0,0 +1,20 @@
+@* A failed Test Connect / browse-open outcome (lmxopcua#847). The chip carries only a short status word; the
+ full refusal text is rendered READABLY under it — wrapping, normal case, selectable — instead of being cut
+ to a few characters inside the (uppercase, inline) chip with the real reason only in a hover tooltip. A
+ secret-reference refusal ("SEC…") was unreadable that way, and a tooltip cannot be copied from.
+ Renders as an inline column, so it drops into the existing d-flex rows beside the button unchanged. *@
+
+ @Label
+ @if (!string.IsNullOrWhiteSpace(Message))
+ {
+ @Message
+ }
+
+
+@code {
+ /// The short chip text (default Failed).
+ [Parameter] public string Label { get; set; } = "Failed";
+
+ /// The full refusal / failure message, shown untruncated under the chip.
+ [Parameter] public string? Message { get; set; }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/SaveRefusalNotice.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/SaveRefusalNotice.razor
new file mode 100644
index 000000000..f479efac4
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/SaveRefusalNotice.razor
@@ -0,0 +1,13 @@
+@* The single save-error area under a driver/device form (lmxopcua#847) — see SaveRefusalNotices. *@
+@foreach (var message in SaveRefusalNotices.Messages(GateError, SaveError))
+{
+
@message
+}
+
+@code {
+ /// The live save-gate refusal for the current form state, or null.
+ [Parameter] public string? GateError { get; set; }
+
+ /// The last save attempt's error, or null.
+ [Parameter] public string? SaveError { get; set; }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/SaveRefusalNotices.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/SaveRefusalNotices.cs
new file mode 100644
index 000000000..54ec54b3c
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/SaveRefusalNotices.cs
@@ -0,0 +1,36 @@
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;
+
+///
+/// The one error area a driver/device modal shows under its form (lmxopcua#847). The modals used to render the
+/// live refusal AND the last save's error as two independent boxes, and
+/// a Save that the gate refused copied the gate text into the save error — so a refused save (e.g. a Galaxy
+/// credential reference outside its namespace) stacked two identical boxes, and a server refusal from an
+/// earlier save could stay standing beside a newer one.
+///
+public static class SaveRefusalNotices
+{
+ /// The distinct messages to render, the live gate refusal first.
+ /// The live save-gate refusal for the current form state, or null.
+ /// The last save attempt's error, or null.
+ /// Zero, one or two messages — never the same text twice, and never a save error that merely
+ /// repeats (contains, or is contained by) the gate refusal already shown.
+ public static IReadOnlyList Messages(string? gateError, string? saveError)
+ {
+ var gate = string.IsNullOrWhiteSpace(gateError) ? null : gateError.Trim();
+ var save = string.IsNullOrWhiteSpace(saveError) ? null : saveError.Trim();
+
+ if (gate is null)
+ {
+ return save is null ? [] : [save];
+ }
+
+ if (save is null
+ || save.Contains(gate, StringComparison.Ordinal)
+ || gate.Contains(save, StringComparison.Ordinal))
+ {
+ return [gate];
+ }
+
+ return [gate, save];
+ }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawBrowseModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawBrowseModal.razor
index 8ced0c544..85eb14864 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawBrowseModal.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawBrowseModal.razor
@@ -63,7 +63,7 @@
@if (_openError is not null)
{
- @Truncate(_openError, 70)
+
}
}
@@ -845,7 +845,6 @@
private string TargetLabel()
=> string.IsNullOrEmpty(_groupPrefix) ? "(device root)" : _groupPrefix!;
- private static string Truncate(string s, int max) => s.Length > max ? s[..max] + "…" : s;
public ValueTask DisposeAsync()
{
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Drivers/SaveRefusalNoticesTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Drivers/SaveRefusalNoticesTests.cs
new file mode 100644
index 000000000..202b6ba38
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Drivers/SaveRefusalNoticesTests.cs
@@ -0,0 +1,86 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Drivers;
+
+///
+/// lmxopcua#847: a refused driver/device save stacked two copies of the same error box (the live gate box plus
+/// the gate text copied into the save error), and a resubmit left the previous refusal standing.
+///
+[Trait("Category", "Unit")]
+public sealed class SaveRefusalNoticesTests
+{
+ [Fact]
+ public void Nothing_to_show_renders_nothing()
+ => SaveRefusalNotices.Messages(null, " ").ShouldBeEmpty();
+
+ [Fact]
+ public void The_same_refusal_from_gate_and_save_renders_once()
+ => SaveRefusalNotices.Messages("Credential refused.", "Credential refused.").ShouldBe(["Credential refused."]);
+
+ [Fact]
+ public void A_save_error_that_wraps_the_gate_text_renders_once()
+ => SaveRefusalNotices.Messages("Credential refused.", "Save failed: Credential refused.")
+ .ShouldBe(["Credential refused."]);
+
+ [Fact]
+ public void Distinct_gate_and_server_errors_both_render_gate_first()
+ => SaveRefusalNotices.Messages("Credential refused.", "Row was modified by someone else.")
+ .ShouldBe(["Credential refused.", "Row was modified by someone else."]);
+
+ [Fact]
+ public void A_server_error_alone_renders()
+ => SaveRefusalNotices.Messages(null, "Duplicate name.").ShouldBe(["Duplicate name."]);
+
+ [Theory]
+ [InlineData("DriverConfigModal.razor", "_driverConfigJson")]
+ [InlineData("DeviceModal.razor", "_deviceConfigJson")]
+ public void Both_modals_render_one_shared_error_area_and_reset_it_on_resubmit_and_edit(string file, string jsonField)
+ {
+ var src = AdminUiSource.Read("Components", "Shared", "Drivers", file);
+
+ src.ShouldContain("@_saveError");
+ // The gate refusal is no longer COPIED into _saveError (the source of the duplicate box).
+ src.ShouldNotContain($"_saveError = DriverConfigSaveGate.Validate(_driverType, {jsonField});");
+ src.ShouldContain("_saveError = null;\n if (DriverConfigSaveGate.Validate(");
+ // An edit clears a stale server refusal.
+ src.ShouldContain($"v => {{ {jsonField} = v; _saveError = null; }}");
+ }
+}
+
+///
+/// lmxopcua#847: Test Connect / browse-open refusals were cut down inside an uppercase chip ("SEC…") with the
+/// real reason only in a hover tooltip. Every site now renders the shared RefusalChip, which prints the
+/// full message under a short chip.
+///
+[Trait("Category", "Unit")]
+public sealed class RefusalChipWiringTests
+{
+ [Theory]
+ [InlineData("Drivers/DriverTestConnectButton.razor")]
+ [InlineData("Raw/RawBrowseModal.razor")]
+ [InlineData("Drivers/Pickers/AbCipAddressPickerBody.razor")]
+ [InlineData("Drivers/Pickers/FOCASAddressPickerBody.razor")]
+ [InlineData("Drivers/Pickers/GalaxyAddressPickerBody.razor")]
+ [InlineData("Drivers/Pickers/OpcUaClientAddressPickerBody.razor")]
+ [InlineData("Drivers/Pickers/SqlAddressPickerBody.razor")]
+ [InlineData("Drivers/Pickers/TwinCATAddressPickerBody.razor")]
+ public void Refusal_sites_render_the_full_message_via_RefusalChip(string relative)
+ {
+ var src = AdminUiSource.Read(["Components", "Shared", .. relative.Split('/')]);
+ src.ShouldContain("@Message");
+ src.ShouldNotContain("Abbreviate");
+ }
+}