Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/ImportEquipmentModal.razor
T
Joseph Doherty 77bc010ba9 v3 Batch 3 WP1: UNS Equipment Tags tab → reference list + raw-tag picker
Replace the Batch-1-stubbed authored-tag flow with a UnsTagReference list on the
equipment page's Tags tab (v3 reference-only equipment): columns are effective name,
computed raw path, inherited datatype/access (read-only), display-name override
(editable), and remove. The retired equipment-authored-tag editor (TagModal.razor)
is deleted; the VirtualTag and ScriptedAlarm flows are untouched.

- "+ Add reference" opens a new AddReferenceModal that reuses RawTree in a new opt-in
  PickerMode (Tag-leaf checkboxes + Device/TagGroup "select all tags below"), scoped
  structurally to the equipment's cluster via LoadReferencePickerRootAsync (a single
  cluster root — cross-cluster tags are unreachable, not merely hidden). Default /raw
  usage is byte-unchanged (PickerMode defaults false).
- UnsTreeService (+ IUnsTreeService) gain the reference mutations:
  LoadReferencesForEquipmentAsync (computes each RawPath via the shared RawPathResolver),
  AddReferencesAsync (cluster-checked, all-or-nothing), RemoveReferenceAsync,
  SetReferenceOverrideAsync, plus the picker helpers LoadReferencePickerRootAsync /
  LoadDescendantTagIdsAsync.
- Consume IEffectiveNameGuard (constructor-injected, no-op fallback when unregistered):
  AddReferences, SetReferenceOverride, and the VirtualTag + ScriptedAlarm create/update
  mutations now call CheckAsync before persisting and surface a collision as the failure.
  WP2 supplies the implementation + DI registration.
- Drop the retired equipment↔driver binding: remove DriverInstanceId from EquipmentInput,
  the ImportEquipmentModal CSV column, the EquipmentPage Details driver select, and the
  dead decision-#122 driver-cluster guard.

Tests: new UnsTreeServiceReferenceTests (add/remove/override, cross-cluster + duplicate
rejection, guard-consumed rejection via a fake guard, RawPath projection, picker helpers);
Equipment/Import test suites rewritten to drop the retired driver-guard cases. AdminUI
suite green (639 passed, 3 skipped); full solution builds 0 errors.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 06:15:51 -04:00

192 lines
8.6 KiB
Plaintext

@* Bulk equipment import modal wired straight into IUnsTreeService. Paste a CSV (header + rows),
the modal parses it into EquipmentInput rows and calls ImportEquipmentAsync, then shows the
Inserted / Skipped / Errors summary in place. The host owns visibility; "Close" raises OnImported
so the page can reload the whole tree (an import can add equipment across many lines/clusters).
Required header columns (in order): Name, MachineCode, UnsLineId.
Optional: ZTag, SAPID, Manufacturer, Model. Existing rows are detected by MachineCode and
skipped (additive-only — no updates), matching the retired /clusters/{id}/equipment/import page.
v3: equipment no longer carries a driver, so the old DriverInstanceId column is gone. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@inject IUnsTreeService Svc
@if (Visible)
{
<div class="modal-backdrop fade show" style="display:block"></div>
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Import equipment CSV</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseAsync"></button>
</div>
<div class="modal-body">
<p class="text-muted small mb-2">
Paste CSV below. Required header columns (in order):
<span class="mono">Name, MachineCode, UnsLineId</span>.
Optional: <span class="mono">ZTag, SAPID, Manufacturer, Model</span>.
Each row inserts one equipment with a freshly-generated EquipmentId. Existing rows
are detected by MachineCode and skipped (additive-only — no updates).
</p>
<textarea class="form-control form-control-sm mono" rows="12"
@bind="_csv" @bind:event="oninput" disabled="@_busy"
placeholder="Name,MachineCode,UnsLineId,ZTag,SAPID,Manufacturer,Model&#10;mixer-01,MX_001,LINE-1,ZT-12345,SAP-9876,Siemens,SIMATIC-1500"></textarea>
<div class="form-text">Simple comma-separated values only — fields containing commas are not supported.</div>
@if (!string.IsNullOrWhiteSpace(_parseError))
{
<div class="text-danger small mt-2">@_parseError</div>
}
@if (_result is not null)
{
<div class="alert alert-info mt-3 mb-0" role="alert">
<div>
<strong>Inserted:</strong> @_result.Inserted
&middot; <strong>Skipped (existing MachineCode):</strong> @_result.Skipped
&middot; <strong>Errors:</strong> @_result.Errors.Count
</div>
@if (_result.Errors.Count > 0)
{
<ul class="small mb-0 mt-2">
@foreach (var err in _result.Errors)
{
<li>@err</li>
}
</ul>
}
</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="CloseAsync" disabled="@_busy">
@(_result is null ? "Cancel" : "Close")
</button>
<button type="button" class="btn btn-primary" @onclick="ImportAsync" disabled="@_busy">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
Import
</button>
</div>
</div>
</div>
</div>
}
@code {
// Required, in order. v3: equipment no longer binds a driver, so DriverInstanceId is gone.
private static readonly string[] RequiredColumns = ["Name", "MachineCode", "UnsLineId"];
/// <summary>Whether the modal is shown. The host owns this flag.</summary>
[Parameter] public bool Visible { get; set; }
/// <summary>
/// Raised when the user closes the modal after an import so the host can reload the tree and
/// dismiss the modal. Fired on close regardless of whether anything was inserted — closing always
/// returns control to the host.
/// </summary>
[Parameter] public EventCallback OnImported { get; set; }
/// <summary>Raised when the user cancels before importing so the host can close.</summary>
[Parameter] public EventCallback OnCancel { get; set; }
private string _csv = "";
private bool _busy;
private string? _parseError;
private EquipmentImportResult? _result;
private bool _wasVisible;
protected override void OnParametersSet()
{
// Reset only on the false→true transition so that re-renders while open don't wipe state.
if (Visible && !_wasVisible)
{
_csv = "";
_parseError = null;
_result = null;
}
_wasVisible = Visible;
}
private async Task ImportAsync()
{
_busy = true;
_parseError = null;
_result = null;
try
{
var rows = ParseCsv(_csv);
if (rows is null) { return; }
_result = await Svc.ImportEquipmentAsync(rows);
}
finally
{
_busy = false;
}
}
/// <summary>
/// Closes the modal. Once an import has run (the user has seen the summary), closing raises
/// OnImported so the host reloads the tree; before any import it raises OnCancel.
/// </summary>
private Task CloseAsync() =>
_result is not null ? OnImported.InvokeAsync() : OnCancel.InvokeAsync();
/// <summary>
/// Parses the pasted CSV into <see cref="EquipmentInput"/> rows. Requires a header row whose first
/// three columns are Name, MachineCode, UnsLineId (case-insensitive); the optional
/// ZTag/SAPID/Manufacturer/Model follow. Blank optional cells parse as <c>null</c>. Returns
/// <c>null</c> (and sets <see cref="_parseError"/>) when the CSV is empty or the header is wrong.
/// </summary>
private List<EquipmentInput>? ParseCsv(string csv)
{
if (string.IsNullOrWhiteSpace(csv)) { _parseError = "CSV is empty."; return null; }
var lines = csv.Replace("\r\n", "\n").Split('\n', StringSplitOptions.RemoveEmptyEntries);
if (lines.Length < 2) { _parseError = "Need a header row and at least one data row."; return null; }
var header = lines[0].Split(',').Select(c => c.Trim()).ToArray();
for (var i = 0; i < RequiredColumns.Length; i++)
{
if (i >= header.Length || !string.Equals(header[i], RequiredColumns[i], StringComparison.OrdinalIgnoreCase))
{
_parseError = $"Header column #{i + 1} must be '{RequiredColumns[i]}' (got '{(i < header.Length ? header[i] : "")}').";
return null;
}
}
var rows = new List<EquipmentInput>();
for (var lineIdx = 1; lineIdx < lines.Length; lineIdx++)
{
// NOTE: simple comma split — no RFC4180 quoting; values with commas are not supported.
var parts = lines[lineIdx].Split(',').Select(c => c.Trim()).ToArray();
if (parts.Length < RequiredColumns.Length)
{
_parseError = $"Row {lineIdx}: too few columns (got {parts.Length}, need {RequiredColumns.Length}).";
return null;
}
rows.Add(new EquipmentInput(
Name: parts[0],
MachineCode: parts[1],
UnsLineId: parts[2],
ZTag: NullIfEmpty(parts, 3),
SAPID: NullIfEmpty(parts, 4),
Manufacturer: NullIfEmpty(parts, 5),
Model: NullIfEmpty(parts, 6),
SerialNumber: null,
HardwareRevision: null,
SoftwareRevision: null,
YearOfConstruction: null,
AssetLocation: null,
ManufacturerUri: null,
DeviceManualUri: null,
Enabled: true));
}
return rows;
}
private static string? NullIfEmpty(string[] parts, int idx) =>
idx < parts.Length && !string.IsNullOrWhiteSpace(parts[idx]) ? parts[idx] : null;
}