diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceModal.razor index fb380aaf8..00c36a140 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceModal.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceModal.razor @@ -62,14 +62,7 @@ - @if (DriverConfigSaveGate.Validate(_driverType, _deviceConfigJson) is { } gateError) - { -
@gateError
- } - @if (_saveError is not null) - { -
@_saveError
- } + } 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)) +{ + +} + +@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"); + } +}