feat(centralui): Create Instance — Connection Bindings card (preview flatten, per-group connection/Object/Browse, Bind-all prefill)
Tasks 4 and 5 of the plan land together: the card's state fields are write-only until the markup consumes them, and the project treats unused-field warnings as errors, so neither task builds on its own. Claude-Session: https://claude.ai/code/session_01G2GHPnbbKGvyyfbr2XBRVH
This commit is contained in:
+164
-6
@@ -3,13 +3,19 @@
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
|
||||
@using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services
|
||||
@using ZB.MOM.WW.ScadaBridge.DeploymentManager
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Dialogs
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
|
||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||
@inject ISiteRepository SiteRepository
|
||||
@inject ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService SiteScope
|
||||
@inject InstanceService InstanceService
|
||||
@inject IFlatteningPipeline FlatteningPipeline
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
@@ -33,7 +39,9 @@
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Template</label>
|
||||
<select class="form-select form-select-sm" @bind="_createTemplateId">
|
||||
<select class="form-select form-select-sm" data-test="create-template"
|
||||
value="@_createTemplateId"
|
||||
@onchange="async e => { _createTemplateId = int.TryParse(e.Value?.ToString(), out var v) ? v : 0; await OnTemplateOrSiteChangedAsync(); }">
|
||||
<option value="0">Select template...</option>
|
||||
@foreach (var t in _templates)
|
||||
{
|
||||
@@ -43,7 +51,9 @@
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Site</label>
|
||||
<select class="form-select form-select-sm" @bind="_createSiteId">
|
||||
<select class="form-select form-select-sm" data-test="create-site"
|
||||
value="@_createSiteId"
|
||||
@onchange="async e => { _createSiteId = int.TryParse(e.Value?.ToString(), out var v) ? v : 0; _createAreaId = 0; await OnTemplateOrSiteChangedAsync(); }">
|
||||
<option value="0">Select site...</option>
|
||||
@foreach (var s in _sites)
|
||||
{
|
||||
@@ -65,12 +75,155 @@
|
||||
{
|
||||
<div class="text-danger small mt-2">@_formError</div>
|
||||
}
|
||||
<div class="btn-group btn-group-sm mt-3" role="group" aria-label="Create instance actions">
|
||||
<button class="btn btn-success" @onclick="CreateInstance">Create</button>
|
||||
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
|
||||
</div>
|
||||
@if (!BindingsCardVisible)
|
||||
{
|
||||
<div class="btn-group btn-group-sm mt-3" role="group" aria-label="Create instance actions">
|
||||
<button class="btn btn-success" @onclick="CreateInstance">Create</button>
|
||||
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Connection Bindings — the same module groups the Configure page shows, built
|
||||
from a template+site PREVIEW flatten (no instance row exists yet). Group
|
||||
headers only: per-member editing stays on Configure. Nothing is saved until
|
||||
Create. Design: docs/plans/2026-08-21-create-instance-bindings-design.md *@
|
||||
@if (BindingsCardVisible)
|
||||
{
|
||||
<div class="card mb-3" data-test="create-bindings-card">
|
||||
<div class="card-header py-2 d-flex align-items-center gap-3 flex-wrap">
|
||||
<strong>Connection Bindings</strong>
|
||||
@if (_previewLoading)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm" role="status"></span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted small">@_rowCount data-sourced attribute(s) in @_groups.Count group(s)</span>
|
||||
}
|
||||
<span class="ms-auto"></span>
|
||||
@if (UnboundGroups > 0 && !_skipBindings)
|
||||
{
|
||||
<span class="badge text-bg-warning" data-test="create-unbound-groups">@UnboundGroups group(s) unbound</span>
|
||||
}
|
||||
<span class="badge border text-muted">preview — nothing saved yet</span>
|
||||
</div>
|
||||
@if (_previewError is not null)
|
||||
{
|
||||
<div class="alert alert-warning rounded-0 mb-0 py-2 small" data-test="create-bindings-preview-error">
|
||||
Could not preview this template at the selected site (@_previewError). You can create the instance and bind on Configure.
|
||||
</div>
|
||||
}
|
||||
else if (!_previewLoading && _rowCount == 0)
|
||||
{
|
||||
<div class="card-body text-muted small">Nothing to bind in this template.</div>
|
||||
}
|
||||
else if (_siteConnections.Count == 0)
|
||||
{
|
||||
<div class="card-body text-muted small">This site has no data connections — add one under Sites, or create now and bind later.</div>
|
||||
}
|
||||
else if (!_skipBindings)
|
||||
{
|
||||
<div class="card-body border-bottom py-2 d-flex align-items-center gap-2 flex-wrap small">
|
||||
<span>Bind all to</span>
|
||||
<select class="form-select form-select-sm" style="width: auto;" data-test="create-root-connection" @bind="_rootConnectionId">
|
||||
<option value="0">Select connection...</option>
|
||||
@foreach (var c in _siteConnections)
|
||||
{
|
||||
<option value="@c.Id">@c.Name (@c.Protocol)</option>
|
||||
}
|
||||
</select>
|
||||
<input class="form-control form-control-sm font-monospace" style="max-width: 260px;" data-test="create-root-object"
|
||||
@bind="_rootObject" placeholder="root object, e.g. Z28063" />
|
||||
<button class="btn btn-outline-primary btn-sm" data-test="create-root-browse"
|
||||
disabled="@(!IsBrowsable(_rootConnectionId))" @onclick="() => OpenBrowser(null)">Browse…</button>
|
||||
<button class="btn btn-outline-primary btn-sm" data-test="create-prefill"
|
||||
disabled="@(_rootConnectionId == 0)" @onclick="PrefillGroups">Prefill groups</button>
|
||||
<span class="text-muted">Fills the connection on every group and suggests an Object for blanks; never overwrites an Object you typed.</span>
|
||||
</div>
|
||||
@foreach (var g in _groups)
|
||||
{
|
||||
var input = _inputs.GetValueOrDefault(g.Key);
|
||||
var source = CreateBindingPlanner.SourceOf(g, input);
|
||||
<div class="binding-group-header d-flex align-items-center gap-2 px-3 py-2 border-bottom" data-test="create-binding-group">
|
||||
<span class="fw-semibold binding-group-name">@g.Key
|
||||
@if (g.IsInstanceGroup)
|
||||
{
|
||||
<small class="text-muted fw-normal ms-1">direct attributes</small>
|
||||
}
|
||||
else if (g.NestedModules.Count > 0)
|
||||
{
|
||||
<small class="text-muted fw-normal ms-1">incl. @string.Join(", ", g.NestedModules)</small>
|
||||
}
|
||||
</span>
|
||||
<span class="text-muted small">@g.Members.Count</span>
|
||||
<select class="form-select form-select-sm" style="width: 160px;" data-test="create-group-connection"
|
||||
value="@(input?.ConnectionId ?? 0)"
|
||||
@onchange="e => SetGroupConnection(g, int.TryParse(e.Value?.ToString(), out var v) ? v : 0)">
|
||||
<option value="0">— none —</option>
|
||||
@foreach (var c in _siteConnections)
|
||||
{
|
||||
<option value="@c.Id">@c.Name</option>
|
||||
}
|
||||
</select>
|
||||
<input class="form-control form-control-sm font-monospace" style="max-width: 260px;" data-test="create-group-object"
|
||||
value="@input?.Object"
|
||||
placeholder="@(g.IsInstanceGroup ? "object, e.g. Z28063" : $"object, e.g. {g.Key}")"
|
||||
title="Object path this module's members hang off; every member binds as <object>.<member>."
|
||||
@onchange="e => SetGroupObject(g, e.Value?.ToString())" />
|
||||
<button class="btn btn-outline-primary btn-sm" data-test="create-group-browse"
|
||||
disabled="@(!IsBrowsable(input?.ConnectionId ?? 0))" @onclick="() => OpenBrowser(g)">Browse…</button>
|
||||
@if (source == CreateBindingPlanner.ObjectSource.Suggested)
|
||||
{
|
||||
<span class="badge text-bg-secondary" title="Suggested from the root object; edit if the object is a sibling">suggested</span>
|
||||
}
|
||||
else if (source == CreateBindingPlanner.ObjectSource.FromTemplate)
|
||||
{
|
||||
<span class="badge text-bg-secondary" title="The composed template's defaults already carry this object">from template</span>
|
||||
}
|
||||
@if (IsGroupBound(g))
|
||||
{
|
||||
<span class="badge text-bg-success">bound</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge text-bg-danger">@g.Members.Count unbound</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
<div class="card-footer d-flex align-items-center gap-2 flex-wrap">
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="Create instance actions">
|
||||
<button class="btn btn-success" @onclick="CreateInstance" disabled="@_previewLoading">Create instance</button>
|
||||
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
|
||||
</div>
|
||||
<label class="form-check form-check-inline mb-0 small ms-2">
|
||||
<input class="form-check-input" type="checkbox" data-test="create-skip-bindings" @bind="_skipBindings" />
|
||||
Skip bindings — I'll bind on Configure
|
||||
</label>
|
||||
<span class="text-muted small ms-auto">
|
||||
@if (_skipBindings)
|
||||
{
|
||||
<text>Creates the instance without bindings.</text>
|
||||
}
|
||||
else
|
||||
{
|
||||
<text>Creates the instance, saves @BoundRowCount binding(s), then opens Configure.@(UnboundGroups > 0 ? $" {UnboundGroups} group(s) stay unbound — Deploy is rejected until they are bound." : "")</text>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Address-space picker. SelectableNodeClasses adds Object so a module's
|
||||
container node can be chosen, not just a leaf tag. *@
|
||||
<NodeBrowserDialog @ref="_browserRef"
|
||||
SiteId="@(_siteIdentifier ?? "")"
|
||||
ConnectionName="@_browserConnectionName"
|
||||
InitialNodeId="@_browserInitial"
|
||||
SelectableNodeClasses="ObjectAndVariable"
|
||||
OnSelected="OnBrowserSelected" />
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -113,6 +266,11 @@
|
||||
{
|
||||
_createAreaId = aid;
|
||||
}
|
||||
|
||||
|
||||
// The query string can preselect a site; a template never is, so this
|
||||
// no-ops today — kept so a future preselect gets the card for free.
|
||||
await OnTemplateOrSiteChangedAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Dialogs;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment;
|
||||
|
||||
/// <summary>
|
||||
/// Code-behind for <c>InstanceCreate.razor</c> — the Connection Bindings card
|
||||
/// (design 2026-08-21). The card flattens the chosen template AGAINST the chosen
|
||||
/// site through <c>IFlatteningPipeline.PreviewAsync</c> (no instance row exists yet),
|
||||
/// groups the data-sourced attributes with the same <see cref="BindingGroupBuilder"/>
|
||||
/// the Configure page uses, and collects one connection + Object per group. The pure
|
||||
/// prefill / tail-strip / binding-list logic lives in <see cref="CreateBindingPlanner"/>
|
||||
/// so it is unit-pinned without rendering the page.
|
||||
/// </summary>
|
||||
public partial class InstanceCreate
|
||||
{
|
||||
// --- Connection Bindings card state (design 2026-08-21) ---
|
||||
private IReadOnlyList<BindingGroup> _groups = Array.Empty<BindingGroup>();
|
||||
private int _rowCount;
|
||||
private List<DataConnection> _siteConnections = new();
|
||||
private readonly Dictionary<string, CreateBindingPlanner.GroupInput> _inputs = new(StringComparer.Ordinal);
|
||||
private int _rootConnectionId;
|
||||
private string? _rootObject;
|
||||
private bool _skipBindings;
|
||||
private bool _previewLoading;
|
||||
private string? _previewError;
|
||||
private string? _siteIdentifier;
|
||||
|
||||
private bool BindingsCardVisible => _createTemplateId > 0 && _createSiteId > 0;
|
||||
|
||||
private int UnboundGroups => _groups.Count(g => !IsGroupBound(g));
|
||||
|
||||
private bool IsGroupBound(BindingGroup g)
|
||||
=> _inputs.TryGetValue(g.Key, out var i) && i.ConnectionId > 0 && !string.IsNullOrWhiteSpace(i.Object);
|
||||
|
||||
private int BoundRowCount => _groups.Where(IsGroupBound).Sum(g => g.Members.Count);
|
||||
|
||||
/// <summary>
|
||||
/// Called from the Template and Site selects' <c>@onchange</c>; re-runs the preview
|
||||
/// flatten and rebuilds the groups. No-ops until both a template and a site are chosen.
|
||||
/// </summary>
|
||||
private async Task OnTemplateOrSiteChangedAsync()
|
||||
{
|
||||
_groups = Array.Empty<BindingGroup>();
|
||||
_inputs.Clear();
|
||||
_previewError = null;
|
||||
_rowCount = 0;
|
||||
if (!BindingsCardVisible) return;
|
||||
|
||||
_previewLoading = true;
|
||||
try
|
||||
{
|
||||
var site = _sites.FirstOrDefault(s => s.Id == _createSiteId);
|
||||
_siteIdentifier = site?.SiteIdentifier;
|
||||
_siteConnections = (await SiteRepository.GetDataConnectionsBySiteIdAsync(_createSiteId)).ToList();
|
||||
|
||||
var preview = await FlatteningPipeline.PreviewAsync(_createTemplateId, _createSiteId);
|
||||
if (preview.IsFailure)
|
||||
{
|
||||
_previewError = preview.Error;
|
||||
return;
|
||||
}
|
||||
|
||||
// Same predicate as the deploy gate (ValidateConnectionBindingCompleteness:
|
||||
// DataSourceReference != null), so every row the gate will check has a row here.
|
||||
var rows = preview.Value.Attributes
|
||||
.Where(a => a.DefaultDataSourceReference is not null || a.DataSourceReference is not null)
|
||||
.Select(a => new BindingRow(a.CanonicalName, a.DefaultDataSourceReference ?? a.DataSourceReference, null, null))
|
||||
.ToList();
|
||||
_rowCount = rows.Count;
|
||||
_groups = BindingGroupBuilder.Build(rows, _siteConnections.Select(c => c.Id).ToHashSet());
|
||||
|
||||
// Groups whose defaults already carry an object prefix start "from template" —
|
||||
// the connection is still the operator's to pick.
|
||||
foreach (var g in _groups)
|
||||
{
|
||||
if (!g.IsInstanceGroup && g.InferredObject is { Length: > 0 })
|
||||
_inputs[g.Key] = new CreateBindingPlanner.GroupInput(0, g.InferredObject);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_previewError = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_previewLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void PrefillGroups()
|
||||
{
|
||||
foreach (var kv in CreateBindingPlanner.Prefill(_groups, _inputs, _rootConnectionId, _rootObject))
|
||||
_inputs[kv.Key] = kv.Value;
|
||||
}
|
||||
|
||||
private void SetGroupConnection(BindingGroup g, int id)
|
||||
=> _inputs[g.Key] = (_inputs.GetValueOrDefault(g.Key) ?? new CreateBindingPlanner.GroupInput(0, null))
|
||||
with { ConnectionId = id };
|
||||
|
||||
private void SetGroupObject(BindingGroup g, string? obj)
|
||||
=> _inputs[g.Key] = (_inputs.GetValueOrDefault(g.Key) ?? new CreateBindingPlanner.GroupInput(0, null))
|
||||
with { Object = obj, Suggested = false };
|
||||
|
||||
// ── Address-space browser ───────────────────────────────────────────────
|
||||
|
||||
/// <summary>Object as well as Variable: a module group binds to a container node.</summary>
|
||||
private static readonly BrowseNodeClass[] ObjectAndVariable = [BrowseNodeClass.Object, BrowseNodeClass.Variable];
|
||||
|
||||
private NodeBrowserDialog? _browserRef;
|
||||
private string _browserConnectionName = "";
|
||||
private string? _browserInitial;
|
||||
private BindingGroup? _browserTarget; // null = the "Bind all to" root picker
|
||||
|
||||
/// <summary>
|
||||
/// True when the selected data connection supports address-space browsing. OPC UA
|
||||
/// and MxGateway both implement <c>IBrowsableDataConnection</c> site-side; other
|
||||
/// protocols return a NotBrowsable failure, so the button stays disabled for them.
|
||||
/// (Mirrors the Configure page's identical gate.)
|
||||
/// </summary>
|
||||
private bool IsBrowsable(int connectionId)
|
||||
{
|
||||
if (connectionId <= 0) return false;
|
||||
var protocol = _siteConnections.FirstOrDefault(c => c.Id == connectionId)?.Protocol;
|
||||
return string.Equals(protocol, "OpcUa", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(protocol, "MxGateway", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the picker for one group, or for the root object when
|
||||
/// <paramref name="target"/> is null. Remembers the target so
|
||||
/// <see cref="OnBrowserSelected"/> writes the pick back to the right input.
|
||||
/// </summary>
|
||||
private async Task OpenBrowser(BindingGroup? target)
|
||||
{
|
||||
var connId = target is null ? _rootConnectionId : _inputs.GetValueOrDefault(target.Key)?.ConnectionId ?? 0;
|
||||
var conn = _siteConnections.FirstOrDefault(c => c.Id == connId);
|
||||
if (conn is null) return;
|
||||
|
||||
_browserTarget = target;
|
||||
_browserConnectionName = conn.Name;
|
||||
_browserInitial = string.IsNullOrWhiteSpace(_rootObject) ? null : _rootObject;
|
||||
|
||||
if (_browserRef is not null)
|
||||
await _browserRef.ShowAsync(_siteIdentifier ?? "", conn.Name, _browserInitial);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A group pick is normalised through <see cref="CreateBindingPlanner.ObjectFromPick"/>,
|
||||
/// so selecting a leaf tag under the module still yields the module's object.
|
||||
/// </summary>
|
||||
private void OnBrowserSelected(string nodeId)
|
||||
{
|
||||
if (_browserTarget is null) _rootObject = nodeId;
|
||||
else SetGroupObject(_browserTarget, CreateBindingPlanner.ObjectFromPick(_browserTarget, nodeId));
|
||||
_browserTarget = null;
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
/* Connection Bindings — group header rows (mirrors InstanceConfigure.razor.css,
|
||||
design 2026-08-21). Create shows group headers only, so no .binding-group rule. */
|
||||
.binding-group-header {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.binding-group-header .form-select,
|
||||
.binding-group-header .form-control {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.binding-group-name {
|
||||
min-width: 10rem;
|
||||
}
|
||||
Reference in New Issue
Block a user