fix(adminui): one save-error area per driver/device modal; refusal chips print the full message (#847)
This commit is contained in:
@@ -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
|
||||
{
|
||||
|
||||
+6
-11
@@ -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
|
||||
{
|
||||
|
||||
+1
-3
@@ -29,9 +29,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="chip chip-bad" title="@_result.Message">
|
||||
Failed · @DisplayText.Abbreviate(_result.Message, 60)
|
||||
</span>
|
||||
<RefusalChip Message="@_result.Message" />
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
+1
-3
@@ -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()
|
||||
{
|
||||
|
||||
+1
-3
@@ -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()
|
||||
{
|
||||
|
||||
+1
-3
@@ -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()
|
||||
{
|
||||
|
||||
+1
-3
@@ -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()
|
||||
{
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+1
-3
@@ -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; }
|
||||
}
|
||||
+13
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user