feat(uns): equipment detail page shell + Details tab + create-redirect
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
@page "/uns/equipment/new"
|
||||
@page "/uns/equipment/{EquipmentId}"
|
||||
@* Dedicated tabbed editor for a single equipment, replacing the /uns EquipmentModal. This task builds
|
||||
the shell + the Details tab (lifted from EquipmentModal's EditForm) + the create→edit redirect; the
|
||||
Tags / Virtual Tags / Alarms tabs render placeholders wired in later tasks. On a successful create the
|
||||
page redirects to /uns/equipment/{newId} so the other tabs (disabled while new) become available. *@
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
|
||||
@inject IUnsTreeService Svc
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<PageTitle>Equipment</PageTitle>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">@(IsNew ? "New equipment" : (_equipment?.Name ?? EquipmentId))</h4>
|
||||
<a href="/uns" class="btn btn-outline-secondary btn-sm">Back to UNS</a>
|
||||
</div>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<p class="text-muted"><span class="spinner-border spinner-border-sm me-1"></span>Loading…</p>
|
||||
}
|
||||
else if (!IsNew && _equipment is null)
|
||||
{
|
||||
<section class="panel notice rise"><span class="mono">@EquipmentId</span> not found.</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="nav nav-tabs mb-3">
|
||||
<li class="nav-item"><button type="button" class="nav-link @TabClass("details")" @onclick='() => _activeTab = "details"'>Details</button></li>
|
||||
<li class="nav-item"><button type="button" class="nav-link @TabClass("tags")" @onclick='() => _activeTab = "tags"' disabled="@IsNew">Tags</button></li>
|
||||
<li class="nav-item"><button type="button" class="nav-link @TabClass("vtags")" @onclick='() => _activeTab = "vtags"' disabled="@IsNew">Virtual Tags</button></li>
|
||||
<li class="nav-item"><button type="button" class="nav-link @TabClass("alarms")" @onclick='() => _activeTab = "alarms"' disabled="@IsNew">Alarms</button></li>
|
||||
</ul>
|
||||
|
||||
@if (_activeTab == "details")
|
||||
{
|
||||
<EditForm Model="_form" OnValidSubmit="SaveAsync" FormName="equipmentDetails">
|
||||
<DataAnnotationsValidator />
|
||||
|
||||
<h6 class="text-muted">Identity</h6>
|
||||
@if (!IsNew)
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label">EquipmentId</label>
|
||||
<input class="form-control form-control-sm mono" value="@_equipment?.EquipmentId" disabled />
|
||||
<div class="form-text">System-generated; never operator-edited.</div>
|
||||
</div>
|
||||
}
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="eq-name">Name</label>
|
||||
<InputText id="eq-name" @bind-Value="_form.Name" class="form-control form-control-sm mono"
|
||||
placeholder="machine-01" />
|
||||
<div class="form-text">UNS level 5 segment; lowercase letters, digits, dashes, up to 32 chars.</div>
|
||||
<ValidationMessage For="@(() => _form.Name)" />
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="eq-machinecode">MachineCode</label>
|
||||
<InputText id="eq-machinecode" @bind-Value="_form.MachineCode" class="form-control form-control-sm mono"
|
||||
placeholder="machine_001" />
|
||||
<ValidationMessage For="@(() => _form.MachineCode)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="eq-line">UNS line</label>
|
||||
<InputSelect id="eq-line" @bind-Value="_form.UnsLineId" class="form-select form-select-sm">
|
||||
<option value="">— pick a line —</option>
|
||||
@foreach (var (id, display) in _lineOptions)
|
||||
{
|
||||
<option value="@id">@display</option>
|
||||
}
|
||||
</InputSelect>
|
||||
<ValidationMessage For="@(() => _form.UnsLineId)" />
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="eq-driver">Driver instance</label>
|
||||
<InputSelect id="eq-driver" @bind-Value="_form.DriverInstanceId" class="form-select form-select-sm">
|
||||
<option value="">(none / driver-less)</option>
|
||||
@foreach (var (id, display) in _driverOptions)
|
||||
{
|
||||
<option value="@id">@display</option>
|
||||
}
|
||||
</InputSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="eq-ztag">ZTag (ERP)</label>
|
||||
<InputText id="eq-ztag" @bind-Value="_form.ZTag" class="form-control form-control-sm" />
|
||||
<div class="form-text">Unique fleet-wide via ExternalIdReservation.</div>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="eq-sap">SAPID</label>
|
||||
<InputText id="eq-sap" @bind-Value="_form.SAPID" class="form-control form-control-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Enabled</label>
|
||||
<div class="form-check form-switch">
|
||||
<InputCheckbox @bind-Value="_form.Enabled" class="form-check-input" />
|
||||
<label class="form-check-label">Surface in deployments</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
<h6 class="text-muted">OPC 40010 identification (optional)</h6>
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3"><label class="form-label">Manufacturer</label><InputText @bind-Value="_form.Manufacturer" class="form-control form-control-sm" /></div>
|
||||
<div class="col-md-4 mb-3"><label class="form-label">Model</label><InputText @bind-Value="_form.Model" class="form-control form-control-sm" /></div>
|
||||
<div class="col-md-4 mb-3"><label class="form-label">SerialNumber</label><InputText @bind-Value="_form.SerialNumber" class="form-control form-control-sm" /></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3"><label class="form-label">HardwareRevision</label><InputText @bind-Value="_form.HardwareRevision" class="form-control form-control-sm" /></div>
|
||||
<div class="col-md-3 mb-3"><label class="form-label">SoftwareRevision</label><InputText @bind-Value="_form.SoftwareRevision" class="form-control form-control-sm" /></div>
|
||||
<div class="col-md-3 mb-3"><label class="form-label">Year of construction</label><InputNumber @bind-Value="_form.YearOfConstruction" class="form-control form-control-sm" /></div>
|
||||
<div class="col-md-3 mb-3"><label class="form-label">AssetLocation</label><InputText @bind-Value="_form.AssetLocation" class="form-control form-control-sm" /></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3"><label class="form-label">ManufacturerUri</label><InputText @bind-Value="_form.ManufacturerUri" class="form-control form-control-sm mono" /></div>
|
||||
<div class="col-md-6 mb-3"><label class="form-label">DeviceManualUri</label><InputText @bind-Value="_form.DeviceManualUri" class="form-control form-control-sm mono" /></div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(_error)) { <div class="text-danger small mt-2">@_error</div> }
|
||||
|
||||
<div class="mt-3">
|
||||
<button type="submit" class="btn btn-primary" disabled="@_busy">
|
||||
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
@(IsNew ? "Create" : "Save changes")
|
||||
</button>
|
||||
</div>
|
||||
</EditForm>
|
||||
}
|
||||
else if (_activeTab == "tags") { <p class="text-muted">Tags tab — wired in a later task.</p> }
|
||||
else if (_activeTab == "vtags") { <p class="text-muted">Virtual Tags tab — wired in a later task.</p> }
|
||||
else if (_activeTab == "alarms") { <p class="text-muted">Alarms tab — wired in a later task.</p> }
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>The equipment id from the route; null/empty on the <c>/new</c> route (create mode).</summary>
|
||||
[Parameter] public string? EquipmentId { get; set; }
|
||||
|
||||
/// <summary>Optional parent line id supplied as a query string on create, used to default the line select.</summary>
|
||||
[SupplyParameterFromQuery] public string? LineId { get; set; }
|
||||
|
||||
private bool IsNew => string.IsNullOrEmpty(EquipmentId);
|
||||
|
||||
private string _activeTab = "details";
|
||||
private bool _loading = true;
|
||||
private bool _busy;
|
||||
private string? _error;
|
||||
private EquipmentEditDto? _equipment;
|
||||
private FormModel _form = new();
|
||||
private IReadOnlyList<(string Id, string Display)> _lineOptions = Array.Empty<(string, string)>();
|
||||
private IReadOnlyList<(string Id, string Display)> _driverOptions = Array.Empty<(string, string)>();
|
||||
|
||||
private string TabClass(string tab) => _activeTab == tab ? "active" : "";
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_loading = true;
|
||||
_error = null;
|
||||
if (!IsNew)
|
||||
{
|
||||
_equipment = await Svc.LoadEquipmentAsync(EquipmentId!);
|
||||
if (_equipment is not null)
|
||||
{
|
||||
LoadFormFrom(_equipment);
|
||||
var ctx = await Svc.LoadEquipmentPickContextAsync(_equipment.UnsLineId);
|
||||
_lineOptions = ctx.Lines;
|
||||
_driverOptions = ctx.Drivers;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_form = new FormModel { UnsLineId = LineId ?? "" };
|
||||
var ctx = await Svc.LoadEquipmentPickContextAsync(LineId);
|
||||
_lineOptions = ctx.Lines;
|
||||
_driverOptions = ctx.Drivers;
|
||||
}
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
/// <summary>Maps a loaded equipment's fields into the working form (mirrors EquipmentModal's edit branch).</summary>
|
||||
private void LoadFormFrom(EquipmentEditDto e)
|
||||
{
|
||||
_form = new FormModel
|
||||
{
|
||||
Name = e.Name,
|
||||
MachineCode = e.MachineCode,
|
||||
UnsLineId = e.UnsLineId,
|
||||
DriverInstanceId = e.DriverInstanceId,
|
||||
ZTag = e.ZTag,
|
||||
SAPID = e.SAPID,
|
||||
Manufacturer = e.Manufacturer,
|
||||
Model = e.Model,
|
||||
SerialNumber = e.SerialNumber,
|
||||
HardwareRevision = e.HardwareRevision,
|
||||
SoftwareRevision = e.SoftwareRevision,
|
||||
YearOfConstruction = e.YearOfConstruction,
|
||||
AssetLocation = e.AssetLocation,
|
||||
ManufacturerUri = e.ManufacturerUri,
|
||||
DeviceManualUri = e.DeviceManualUri,
|
||||
Enabled = e.Enabled,
|
||||
};
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
_busy = true;
|
||||
_error = null;
|
||||
try
|
||||
{
|
||||
var input = new EquipmentInput(
|
||||
_form.Name,
|
||||
_form.MachineCode,
|
||||
_form.UnsLineId,
|
||||
_form.DriverInstanceId,
|
||||
_form.ZTag,
|
||||
_form.SAPID,
|
||||
_form.Manufacturer,
|
||||
_form.Model,
|
||||
_form.SerialNumber,
|
||||
_form.HardwareRevision,
|
||||
_form.SoftwareRevision,
|
||||
_form.YearOfConstruction,
|
||||
_form.AssetLocation,
|
||||
_form.ManufacturerUri,
|
||||
_form.DeviceManualUri,
|
||||
_form.Enabled);
|
||||
|
||||
var result = IsNew
|
||||
? await Svc.CreateEquipmentAsync(input)
|
||||
: await Svc.UpdateEquipmentAsync(_equipment!.EquipmentId, input, _equipment.RowVersion);
|
||||
|
||||
if (!result.Ok) { _error = result.Error; return; }
|
||||
|
||||
if (IsNew)
|
||||
{
|
||||
// Redirect to the persisted editor so the other tabs (disabled while new) become available.
|
||||
Nav.NavigateTo($"/uns/equipment/{result.CreatedId}");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Reload to pick up the fresh RowVersion for the next save.
|
||||
_equipment = await Svc.LoadEquipmentAsync(EquipmentId!);
|
||||
if (_equipment is not null) { LoadFormFrom(_equipment); }
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The working form for the Details tab — identical to EquipmentModal.FormModel.</summary>
|
||||
private sealed class FormModel
|
||||
{
|
||||
[Required, RegularExpression("^[a-z0-9-]{1,32}$", ErrorMessage = "Lowercase letters, digits, dashes only; max 32 chars.")]
|
||||
public string Name { get; set; } = "";
|
||||
[Required] public string MachineCode { get; set; } = "";
|
||||
[Required] public string UnsLineId { get; set; } = "";
|
||||
public string? DriverInstanceId { get; set; }
|
||||
public string? ZTag { get; set; }
|
||||
public string? SAPID { get; set; }
|
||||
public string? Manufacturer { get; set; }
|
||||
public string? Model { get; set; }
|
||||
public string? SerialNumber { get; set; }
|
||||
public string? HardwareRevision { get; set; }
|
||||
public string? SoftwareRevision { get; set; }
|
||||
public short? YearOfConstruction { get; set; }
|
||||
public string? AssetLocation { get; set; }
|
||||
public string? ManufacturerUri { get; set; }
|
||||
public string? DeviceManualUri { get; set; }
|
||||
public bool Enabled { get; set; } = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
|
||||
/// <summary>The UNS-line and driver options the equipment page's Details tab offers, scoped to the
|
||||
/// cluster that owns a given line. Empty lists when the line can't be resolved to a cluster.</summary>
|
||||
public sealed record EquipmentPickContext(
|
||||
IReadOnlyList<(string Id, string Display)> Lines,
|
||||
IReadOnlyList<(string Id, string Display)> Drivers);
|
||||
@@ -205,6 +205,18 @@ public interface IUnsTreeService
|
||||
/// <returns>The cluster's drivers projected to <c>(DriverInstanceId, Display)</c> pairs.</returns>
|
||||
Task<IReadOnlyList<(string DriverInstanceId, string Display)>> LoadDriversForClusterAsync(string clusterId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Loads the UNS-line and driver <c>(Id, Display)</c> option lists the equipment page's Details tab
|
||||
/// offers, scoped to the cluster that owns the supplied line: every line in that cluster (for the
|
||||
/// line picker) and that cluster's drivers (reusing <see cref="LoadDriversForClusterAsync"/>).
|
||||
/// Centralizes the resolution the page would otherwise need the loaded tree to perform. Returns
|
||||
/// empty lists when <paramref name="lineId"/> is null/empty or cannot be resolved to a cluster.
|
||||
/// </summary>
|
||||
/// <param name="lineId">The line whose owning cluster scopes the option lists; null/empty yields empties.</param>
|
||||
/// <param name="ct">A token to cancel the load.</param>
|
||||
/// <returns>The cluster-scoped line and driver options, or empty lists when the line can't be resolved.</returns>
|
||||
Task<EquipmentPickContext> LoadEquipmentPickContextAsync(string? lineId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new UNS area under a cluster. Fails if an area with the same id already exists.
|
||||
/// Whitespace-only notes are stored as <c>null</c>.
|
||||
|
||||
@@ -266,6 +266,33 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<EquipmentPickContext> LoadEquipmentPickContextAsync(string? lineId, CancellationToken ct = default)
|
||||
{
|
||||
var empty = new EquipmentPickContext(Array.Empty<(string, string)>(), Array.Empty<(string, string)>());
|
||||
if (string.IsNullOrEmpty(lineId)) return empty;
|
||||
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
// line -> area -> clusterId
|
||||
var clusterId = await (from l in db.UnsLines.AsNoTracking()
|
||||
join a in db.UnsAreas.AsNoTracking() on l.UnsAreaId equals a.UnsAreaId
|
||||
where l.UnsLineId == lineId
|
||||
select a.ClusterId).FirstOrDefaultAsync(ct);
|
||||
if (string.IsNullOrEmpty(clusterId)) return empty;
|
||||
|
||||
// all lines in that cluster (for the line <select>)
|
||||
var lines = await (from l in db.UnsLines.AsNoTracking()
|
||||
join a in db.UnsAreas.AsNoTracking() on l.UnsAreaId equals a.UnsAreaId
|
||||
where a.ClusterId == clusterId
|
||||
orderby l.Name
|
||||
select new { l.UnsLineId, l.Name }).ToListAsync(ct);
|
||||
|
||||
var lineOptions = lines.Select(x => (x.UnsLineId, x.Name)).ToList();
|
||||
var drivers = await LoadDriversForClusterAsync(clusterId, ct);
|
||||
return new EquipmentPickContext(lineOptions, drivers);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UnsMutationResult> CreateAreaAsync(
|
||||
string clusterId,
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies <see cref="UnsTreeService.LoadEquipmentPickContextAsync"/> resolves a line to its owning
|
||||
/// cluster and returns that cluster's lines (for the equipment page's Details-tab line picker), and
|
||||
/// returns empty option lists when the line is null or cannot be resolved.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class UnsTreeServiceEquipmentPickContextTests
|
||||
{
|
||||
private static UnsTreeService SeededService()
|
||||
{
|
||||
var dbName = $"uns-pickctx-{Guid.NewGuid():N}";
|
||||
UnsTreeTestDb.SeedNamed(dbName);
|
||||
return new UnsTreeService(UnsTreeTestDb.Factory(dbName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolvesClusterLines_forKnownLine()
|
||||
{
|
||||
var ctx = await SeededService().LoadEquipmentPickContextAsync("LINE-1");
|
||||
ctx.Lines.ShouldContain(o => o.Id == "LINE-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Empty_for_null_or_unknown_line()
|
||||
{
|
||||
var svc = SeededService();
|
||||
(await svc.LoadEquipmentPickContextAsync(null)).Lines.ShouldBeEmpty();
|
||||
(await svc.LoadEquipmentPickContextAsync("NOPE")).Lines.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user