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:
Joseph Doherty
2026-08-21 06:11:53 -04:00
parent ab6e3432bd
commit 74c7bd2fa8
3 changed files with 338 additions and 6 deletions
@@ -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)
{