fix(adminui): one save-error area per driver/device modal; refusal chips print the full message (#847)

This commit is contained in:
Joseph Doherty
2026-11-11 03:41:56 -05:00
parent 01a2013ea3
commit 3b1b32f0c1
14 changed files with 175 additions and 43 deletions
@@ -62,14 +62,7 @@
<DriverTestConnectButton DriverType="@_driverType" GetConfigJson="@BuildMergedProbeConfig" />
</div>
@if (DriverConfigSaveGate.Validate(_driverType, _deviceConfigJson) is { } gateError)
{
<div class="panel notice notice-alert mt-3">@gateError</div>
}
@if (_saveError is not null)
{
<div class="panel notice notice-alert mt-3">@_saveError</div>
}
<SaveRefusalNotice GateError="@DriverConfigSaveGate.Validate(_driverType, _deviceConfigJson)" SaveError="@_saveError" />
}
</div>
<div class="modal-footer">
@@ -122,7 +115,7 @@
{
["DeviceConfigJson"] = _deviceConfigJson,
["DeviceConfigJsonChanged"] = _deviceConfigJsonChanged ??=
EventCallback.Factory.Create<string>(this, v => _deviceConfigJson = v),
EventCallback.Factory.Create<string>(this, v => { _deviceConfigJson = v; _saveError = null; }),
});
private readonly ParameterDictionaryCache _deviceFormParameterCache = new();
private EventCallback<string>? _deviceConfigJsonChanged;
@@ -200,8 +193,10 @@
private async Task SaveAsync()
{
_saveError = DriverConfigSaveGate.Validate(_driverType, _deviceConfigJson);
if (_saveError is not null) return;
// A resubmit REPLACES the previous refusal; a gate refusal is already rendered live by
// SaveRefusalNotice, so it is not copied into _saveError (that stacked two boxes — lmxopcua#847).
_saveError = null;
if (DriverConfigSaveGate.Validate(_driverType, _deviceConfigJson) is not null) return;
_busy = true;
try
{
@@ -63,14 +63,7 @@
</p>
}
@if (DriverConfigSaveGate.Validate(_driverType, _driverConfigJson) is { } gateError)
{
<div class="panel notice notice-alert mt-3">@gateError</div>
}
@if (_saveError is not null)
{
<div class="panel notice notice-alert mt-3">@_saveError</div>
}
<SaveRefusalNotice GateError="@DriverConfigSaveGate.Validate(_driverType, _driverConfigJson)" SaveError="@_saveError" />
}
</div>
<div class="modal-footer">
@@ -126,7 +119,7 @@
{
["DriverConfigJson"] = _driverConfigJson,
["DriverConfigJsonChanged"] = _driverConfigJsonChanged ??=
EventCallback.Factory.Create<string>(this, v => _driverConfigJson = v),
EventCallback.Factory.Create<string>(this, v => { _driverConfigJson = v; _saveError = null; }),
["ResilienceConfig"] = _resilienceConfig!,
["ResilienceConfigChanged"] = _resilienceConfigChanged ??=
EventCallback.Factory.Create<string?>(this, v => _resilienceConfig = v),
@@ -190,8 +183,10 @@
private async Task SaveAsync()
{
_saveError = DriverConfigSaveGate.Validate(_driverType, _driverConfigJson);
if (_saveError is not null) return;
// A resubmit REPLACES the previous refusal; a gate refusal is already rendered live by
// SaveRefusalNotice, so it is not copied into _saveError (that stacked two boxes — lmxopcua#847).
_saveError = null;
if (DriverConfigSaveGate.Validate(_driverType, _driverConfigJson) is not null) return;
_busy = true;
try
{
@@ -29,9 +29,7 @@
}
else
{
<span class="chip chip-bad" title="@_result.Message">
Failed &middot; @DisplayText.Abbreviate(_result.Message, 60)
</span>
<RefusalChip Message="@_result.Message" />
}
}
</div>
@@ -57,7 +57,7 @@
</button>
</div>
}
@if (_openError is not null) { <span class="chip chip-bad" title="@_openError">@TruncatedError()</span> }
@if (_openError is not null) { <RefusalChip Message="@_openError" /> }
</div>
@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()
{
@@ -121,7 +121,7 @@
</button>
</div>
}
@if (_openError is not null) { <span class="chip chip-bad" title="@_openError">@TruncatedError()</span> }
@if (_openError is not null) { <RefusalChip Message="@_openError" /> }
</div>
@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()
{
@@ -42,7 +42,7 @@
<span class="chip chip-ok">Browser open</span>
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="CloseBrowseAsync">Close</button>
}
@if (_openError is not null) { <span class="chip chip-bad" title="@_openError">@TruncatedError()</span> }
@if (_openError is not null) { <RefusalChip Message="@_openError" /> }
</div>
@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()
{
@@ -40,7 +40,7 @@
Close
</button>
}
@if (_openError is not null) { <span class="chip chip-bad" title="@_openError">@TruncatedError()</span> }
@if (_openError is not null) { <RefusalChip Message="@_openError" /> }
</div>
@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()
{
@@ -156,7 +156,7 @@ else
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="CloseBrowseAsync">Close</button>
}
@* Same open-error chip the five sibling pickers use: abbreviated inline, full text on title. *@
@if (_openError is not null) { <span class="chip chip-bad" title="@_openError">@DisplayText.Abbreviate(_openError, 60)</span> }
@if (_openError is not null) { <RefusalChip Message="@_openError" /> }
</div>
@if (_token != Guid.Empty)
@@ -46,7 +46,7 @@
</button>
</div>
}
@if (_openError is not null) { <span class="chip chip-bad" title="@_openError">@TruncatedError()</span> }
@if (_openError is not null) { <RefusalChip Message="@_openError" /> }
</div>
@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()
{
@@ -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. *@
<span class="d-inline-flex flex-column align-items-start" style="min-width:0;max-width:100%">
<span class="chip chip-bad">@Label</span>
@if (!string.IsNullOrWhiteSpace(Message))
{
<span class="small text-danger text-break mt-1" style="white-space:pre-wrap;user-select:text">@Message</span>
}
</span>
@code {
/// <summary>The short chip text (default <c>Failed</c>).</summary>
[Parameter] public string Label { get; set; } = "Failed";
/// <summary>The full refusal / failure message, shown untruncated under the chip.</summary>
[Parameter] public string? Message { get; set; }
}
@@ -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))
{
<div class="panel notice notice-alert mt-3" role="alert">@message</div>
}
@code {
/// <summary>The live save-gate refusal for the current form state, or null.</summary>
[Parameter] public string? GateError { get; set; }
/// <summary>The last save attempt's error, or null.</summary>
[Parameter] public string? SaveError { get; set; }
}
@@ -0,0 +1,36 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;
/// <summary>
/// The one error area a driver/device modal shows under its form (lmxopcua#847). The modals used to render the
/// live <see cref="Uns.DriverConfigSaveGate"/> 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.
/// </summary>
public static class SaveRefusalNotices
{
/// <summary>The distinct messages to render, the live gate refusal first.</summary>
/// <param name="gateError">The live save-gate refusal for the current form state, or null.</param>
/// <param name="saveError">The last save attempt's error, or null.</param>
/// <returns>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.</returns>
public static IReadOnlyList<string> 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];
}
}
@@ -63,7 +63,7 @@
</button>
@if (_openError is not null)
{
<span class="chip chip-bad" title="@_openError">@Truncate(_openError, 70)</span>
<RefusalChip Message="@_openError" />
}
</div>
}
@@ -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()
{
@@ -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;
/// <summary>
/// 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.
/// </summary>
[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("<SaveRefusalNotice ");
src.ShouldNotContain("<div class=\"panel notice notice-alert mt-3\">@_saveError</div>");
// 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; }}");
}
}
/// <summary>
/// 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 <c>RefusalChip</c>, which prints the
/// full message under a short chip.
/// </summary>
[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("<RefusalChip Message=");
src.ShouldNotContain("TruncatedError()");
src.ShouldNotContain("<span class=\"chip chip-bad\" title=");
}
[Fact]
public void RefusalChip_prints_the_message_untruncated_and_wrapping()
{
var src = AdminUiSource.Read("Components", "Shared", "Drivers", "RefusalChip.razor");
src.ShouldContain("text-break");
src.ShouldContain(">@Message</span>");
src.ShouldNotContain("Abbreviate");
}
}