docs(plans): create-time connection bindings — design (inline card on Create Instance, object-node Browse, land on Configure) + 9-task plan
Claude-Session: https://claude.ai/code/session_01Rs5tk49eMrNcwmi8FAohHX
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
# Create Instance — bind connections at creation (design)
|
||||
|
||||
**Date:** 2026-08-21 · **Status:** approved in conversation, awaiting implementation plan
|
||||
**Mockup:** Claude artifact "Create Instance Bindings" (https://claude.ai/code/artifact/125dc749-c923-4977-a02a-eeac826e6f26)
|
||||
**Builds on:** `docs/plans/2026-08-20-grouped-connection-bindings-design.md` (Configure page groups, `BindingGroupBuilder`, per-module Object apply)
|
||||
|
||||
## 1. Problem
|
||||
|
||||
`/deployment/instances/create` stops at Name / Template / Site / Area. The operator then has to
|
||||
find the new instance in Topology, open Configure, and bind every module group before the first
|
||||
Deploy — for a composed template like the MES Line Controller that is 8 groups / 270 rows, and
|
||||
the most common question at creation is exactly "which MxAccess object (or OPC UA node) does this
|
||||
instance hang off?". The answer should be given at creation, in the same shape the Configure page
|
||||
now uses.
|
||||
|
||||
## 2. Decisions (made 2026-08-21)
|
||||
|
||||
| Decision | Choice | Alternatives considered |
|
||||
|---|---|---|
|
||||
| Shape | **Inline bindings card** on the create page — one row per module group (connection, Object, Browse…), with a "Bind all to" root picker; per-member editing stays on Configure. | Two-step wizard with the full grouped table (more screen, duplicates Configure); create-then-redirect to Configure with a banner (no new UI, but still two places). |
|
||||
| What Browse… picks | **The object node.** Selecting an `Object` node writes its id into the group's Object box; members become `<object>.<memberTail>` via the existing `ApplyObject`. Picking a Variable is still allowed; the page strips the member tail. | Leaf-only selection + inference (works today, but non-obvious). |
|
||||
| After Create | **Land on the new instance's Configure page** with a "created, N bound" notice, so Test Bindings / Deploy are one click away. | Back to Topology (today). |
|
||||
|
||||
## 3. Design
|
||||
|
||||
### 3.1 Preview flatten (DeploymentManager, additive)
|
||||
|
||||
The Configure page gets its rows from `IFlatteningPipeline.FlattenAndValidateAsync(instanceId)`,
|
||||
which loads the instance from the DB. At creation there is no row yet, but
|
||||
`FlatteningService.Flatten(Instance, chain, compositionMap, composedChains, dataConnections)` takes
|
||||
an in-memory `Instance`. Add
|
||||
|
||||
```csharp
|
||||
Task<Result<FlattenedConfiguration>> PreviewAsync(int templateId, int siteId, CancellationToken ct = default);
|
||||
```
|
||||
|
||||
to `IFlatteningPipeline`, implemented in `FlatteningPipeline` by factoring the chain/composition
|
||||
loading out of `FlattenAndValidateAsync` into a private helper and calling `Flatten` with an
|
||||
unsaved `new Instance { TemplateId, SiteId, Name = "(preview)" }` (no overrides, no bindings).
|
||||
No validation pass (no scripts, no binding completeness) — only the attribute list is needed.
|
||||
Same `FlattenSession` memo, so it is one template-chain walk per call.
|
||||
|
||||
### 3.2 Create page (CentralUI `InstanceCreate.razor` + new code-behind)
|
||||
|
||||
- The **Connection Bindings** card appears once Template *and* Site are set; changing either
|
||||
re-runs the preview (debounced by the select's change event — no typing involved) and resets
|
||||
the card. Rows = `PreviewAsync(...).Attributes.Where(DefaultDataSourceReference ?? DataSourceReference != null)`
|
||||
→ `BindingRow(canonicalName, default, null, null)` → `BindingGroupBuilder.Build(rows, siteConnectionIds)`.
|
||||
- **Group rows** are the Configure page's group-header row (connection select, Object input,
|
||||
Browse…, bound/unbound pill) without the member table. A third pill on the Object cell says
|
||||
where the value came from: `from template` (the composed defaults already share a prefix —
|
||||
`InferredObject` non-null before any edit), `suggested` (set by Prefill), nothing (typed).
|
||||
- **Bind all to** = connection + root Object + Browse… + **Prefill groups**. Prefill sets the
|
||||
connection on every group, puts the root on `(instance)`, and for module groups with a blank
|
||||
Object whose defaults are bare member names suggests `{root}.{groupKey}`. It never overwrites
|
||||
a typed Object; groups with `from template` keep theirs. The suggestion is deliberately
|
||||
*not* applied silently — sibling galaxy objects (Z28061's `Left_002` vs `Z28061.Left_002`) are
|
||||
common, so the operator must be able to see and correct it before Create.
|
||||
- **Browse…** per group opens `NodeBrowserDialog` for that group's connection, rooted at the root
|
||||
Object when set (`InitialNodeId`), with the new `SelectableNodeClasses = [Object, Variable]`.
|
||||
Object pick → Object box as-is. Variable pick → if its id ends with `.<memberTail>` of any
|
||||
member in the group, strip the tail; otherwise use as-is.
|
||||
- **Skip bindings** checkbox collapses the card and restores today's behaviour.
|
||||
- **Create instance**: validate name/template/site/scope as today; `CreateInstanceAsync`; then,
|
||||
unless skipped, compute the binding list by running `ApplyObject` on every group with a
|
||||
connection and a non-blank Object (rows with connection but blank Object are left unbound —
|
||||
no partial `<member>`-only binding is written) and call `SetConnectionBindingsAsync` (an upsert).
|
||||
Navigate to `/deployment/instances/{id}/configure?created=1&bound=N`. If the bindings call
|
||||
fails **after** the instance was created, still navigate to Configure and pass the error
|
||||
(`?bindError=…`, length-capped) so the operator is not left on a form for an instance that now
|
||||
exists.
|
||||
- State table (empty template, site with no connections, preview failure, in-flight) is in the
|
||||
mockup §3; all non-fatal states keep Create enabled and create without bindings.
|
||||
|
||||
Pure logic lives in a new `Services/CreateBindingPlanner.cs` (Prefill, tail-stripping, binding
|
||||
list assembly) so it is unit-testable without rendering, mirroring `BindingGroupBuilder`.
|
||||
|
||||
### 3.3 NodeBrowserDialog (CentralUI, additive)
|
||||
|
||||
New parameter `IReadOnlyCollection<BrowseNodeClass> SelectableNodeClasses` defaulting to
|
||||
`[Variable]`, consulted by `Select`/`SelectBrowseNode`; rows of a selectable non-Variable class
|
||||
get an `object` badge and the footer prompt reads "Select an object". Existing callers
|
||||
(Configure, template editors) are unchanged.
|
||||
|
||||
### 3.4 Configure page
|
||||
|
||||
Reads `created`/`bound`/`bindError` query parameters and shows a one-shot notice above the
|
||||
Connection Bindings panel. Nothing else changes.
|
||||
|
||||
### 3.5 CLI
|
||||
|
||||
No new command: `instance create` followed by `instance bind-module` / `set-bindings` already
|
||||
covers the same operation. The CLI README gets a one-line cross-reference from the create
|
||||
command to the binding commands.
|
||||
|
||||
## 4. Out of scope
|
||||
|
||||
- Per-member editing at creation (Configure's job).
|
||||
- Making OPC UA object concatenation work for servers with opaque node ids — unchanged caveat
|
||||
from the Configure design; the default reference is visible on Configure for those.
|
||||
- A template-level "preferred connection" default (would save the Bind-all step) — possible
|
||||
follow-up, needs a template schema change.
|
||||
- Test Bindings at creation (needs a saved instance; it is one click away on Configure).
|
||||
|
||||
## 5. Testing
|
||||
|
||||
- `FlatteningPipelineTests`: `PreviewAsync` returns the same attribute set (canonical names +
|
||||
default references) as `FlattenAndValidateAsync` on a freshly created, un-overridden instance of
|
||||
the same template/site; unknown template/site → Failure.
|
||||
- `CreateBindingPlannerTests`: Prefill (blank-only, `from template` preserved, nested module
|
||||
keys), Variable-pick tail stripping, binding list excludes connection-without-Object rows,
|
||||
Skip ⇒ empty list.
|
||||
- `NodeBrowserDialog` bUnit test (if the project already renders it) or `TreeRow` selection
|
||||
test: Object selectable only when listed.
|
||||
- Live check on the docker rig with a composed template: create → lands on Configure with the
|
||||
expected groups bound; Deploy gate accepts; Test Bindings reads.
|
||||
@@ -0,0 +1,815 @@
|
||||
# Create Instance Bindings Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Let the operator bind every module group of a new instance to an MxAccess object / OPC UA node on the Create Instance page, so one Create yields an instance that is deployable (or lands on Configure with only the genuinely undecided groups unbound).
|
||||
|
||||
**Architecture:** A new `IFlatteningPipeline.PreviewAsync(templateId, siteId)` flattens an unsaved `Instance` so the create page can build the same `BindingGroup`s the Configure page shows. A pure `CreateBindingPlanner` (CentralUI `Services/`) holds Prefill / tail-stripping / binding-list assembly and is unit-pinned like `BindingGroupBuilder`. `InstanceCreate.razor` gains the Connection Bindings card (group-header rows only), `NodeBrowserDialog` gains an additive `SelectableNodeClasses` parameter so an Object node can be picked, and Create = `CreateInstanceAsync` → `SetConnectionBindingsAsync` (upsert) → navigate to Configure, which shows a one-shot notice. Design: `docs/plans/2026-08-21-create-instance-bindings-design.md`; mockup: artifact "Create Instance Bindings".
|
||||
|
||||
**Tech Stack:** .NET 10, Blazor Server + Bootstrap (no component libs), xunit + NSubstitute + bUnit, existing `BindingGroupBuilder`, `FlatteningService`.
|
||||
|
||||
**Worktree rule:** implement in a dedicated `git worktree` off `main`, never in the primary checkout. Do NOT use `git stash` (shared across worktrees). `docker/deploy.sh` refuses to run from a worktree — for the live check use `docker/build.sh` from the worktree then `docker compose -f <main>/docker/docker-compose.yml up -d --force-recreate`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `IFlatteningPipeline.PreviewAsync` (DeploymentManager)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 2, Task 3
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/IFlatteningPipeline.cs`
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/FlatteningPipeline.cs` (chain/composition loading lines ~84-135 factored into a helper)
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/FlatteningPipelinePreviewTests.cs` (create)
|
||||
|
||||
`FlatteningPipeline` is the only implementor of the interface (verified), so the interface addition breaks nothing else; NSubstitute mocks elsewhere pick it up automatically.
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
|
||||
```csharp
|
||||
using NSubstitute;
|
||||
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.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
|
||||
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
|
||||
|
||||
/// <summary>Pins PreviewAsync: same attribute set as a freshly created, un-overridden instance.</summary>
|
||||
public class FlatteningPipelinePreviewTests
|
||||
{
|
||||
private const int TemplateId = 10, ModuleTemplateId = 11, SiteId = 20, InstanceId = 30;
|
||||
private readonly ITemplateEngineRepository _templateRepo = Substitute.For<ITemplateEngineRepository>();
|
||||
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
|
||||
private readonly ISharedSchemaRepository _sharedSchemaRepo = Substitute.For<ISharedSchemaRepository>();
|
||||
private readonly FlatteningPipeline _sut;
|
||||
|
||||
public FlatteningPipelinePreviewTests()
|
||||
{
|
||||
_sharedSchemaRepo.ListAsync(Arg.Any<CancellationToken>()).Returns([]);
|
||||
_sut = new FlatteningPipeline(_templateRepo, _siteRepo, new FlatteningService(), new ValidationService(),
|
||||
new RevisionHashService(), _sharedSchemaRepo, new TemplateGraphWatermark());
|
||||
|
||||
// Parent template with one direct data-sourced attribute composing a module with one.
|
||||
var module = new Template("Receiver") { Id = ModuleTemplateId };
|
||||
module.Attributes.Add(new TemplateAttribute("MoveInFlag") { DataType = DataType.Boolean, DataSourceReference = "MoveInFlag" });
|
||||
var parent = new Template("Line") { Id = TemplateId };
|
||||
parent.Attributes.Add(new TemplateAttribute("Running") { DataType = DataType.Boolean, DataSourceReference = "Running" });
|
||||
var composition = new TemplateComposition { TemplateId = TemplateId, ComposedTemplateId = ModuleTemplateId, ModuleInstanceName = "Left" };
|
||||
|
||||
_templateRepo.GetTemplateWithChildrenAsync(TemplateId, Arg.Any<CancellationToken>()).Returns(parent);
|
||||
_templateRepo.GetTemplateWithChildrenAsync(ModuleTemplateId, Arg.Any<CancellationToken>()).Returns(module);
|
||||
_templateRepo.GetCompositionsByTemplateIdAsync(TemplateId, Arg.Any<CancellationToken>()).Returns([composition]);
|
||||
_templateRepo.GetCompositionsByTemplateIdAsync(ModuleTemplateId, Arg.Any<CancellationToken>()).Returns([]);
|
||||
_templateRepo.GetAllSharedScriptsAsync(Arg.Any<CancellationToken>()).Returns([]);
|
||||
_templateRepo.GetInstanceByIdAsync(InstanceId, Arg.Any<CancellationToken>())
|
||||
.Returns(new Instance("Line-01") { Id = InstanceId, TemplateId = TemplateId, SiteId = SiteId });
|
||||
_siteRepo.GetDataConnectionsBySiteIdAsync(SiteId, Arg.Any<CancellationToken>())
|
||||
.Returns([new DataConnection("MxGateway", "MxGateway", SiteId) { Id = 1 }]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Preview_matches_flatten_of_fresh_instance()
|
||||
{
|
||||
var preview = await _sut.PreviewAsync(TemplateId, SiteId);
|
||||
var real = await _sut.FlattenAndValidateAsync(InstanceId, validateScripts: false);
|
||||
|
||||
Assert.True(preview.IsSuccess, preview.Error);
|
||||
var expected = real.Value.Configuration.Attributes.Select(a => (a.CanonicalName, a.DefaultDataSourceReference ?? a.DataSourceReference)).OrderBy(x => x.Item1).ToList();
|
||||
var actual = preview.Value.Attributes.Select(a => (a.CanonicalName, a.DefaultDataSourceReference ?? a.DataSourceReference)).OrderBy(x => x.Item1).ToList();
|
||||
Assert.Equal(expected, actual);
|
||||
Assert.Contains(actual, a => a.Item1 == "Left.MoveInFlag");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Preview_unknown_template_fails()
|
||||
{
|
||||
var r = await _sut.PreviewAsync(999, SiteId);
|
||||
Assert.True(r.IsFailure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Preview_never_touches_instance_repository()
|
||||
{
|
||||
await _sut.PreviewAsync(TemplateId, SiteId);
|
||||
await _templateRepo.DidNotReceive().GetInstanceByIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Check the `TemplateComposition` constructor/property names against `src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Templates/TemplateComposition.cs` and adjust the object initializer (the entity may use a ctor).
|
||||
|
||||
**Step 2: Run to verify failure**
|
||||
|
||||
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests --filter FlatteningPipelinePreviewTests`
|
||||
Expected: build error — `PreviewAsync` not defined.
|
||||
|
||||
**Step 3: Implement**
|
||||
|
||||
`IFlatteningPipeline.cs` — add:
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Flattens <paramref name="templateId"/> as if an instance with no overrides and no
|
||||
/// bindings existed at <paramref name="siteId"/>. Attribute list only — no validation,
|
||||
/// no revision hash, nothing persisted. Used by the Create Instance page to show the
|
||||
/// module groups before the instance row exists.
|
||||
/// </summary>
|
||||
Task<Result<FlattenedConfiguration>> PreviewAsync(int templateId, int siteId, CancellationToken cancellationToken = default);
|
||||
```
|
||||
|
||||
`FlatteningPipeline.cs` — extract the chain + composition walk (from `var templateChain = await session.GetChainAsync(instance.TemplateId, LoadTemplate);` through the end of the `while (pendingChains.Count > 0)` loop) into
|
||||
|
||||
```csharp
|
||||
private async Task<(IReadOnlyList<Template> Chain,
|
||||
Dictionary<int, IReadOnlyList<TemplateComposition>> CompositionMap,
|
||||
Dictionary<int, IReadOnlyList<Template>> ComposedChains)?>
|
||||
LoadTemplateGraphAsync(int templateId, FlattenSession session, CancellationToken cancellationToken)
|
||||
```
|
||||
|
||||
returning `null` when the chain is empty; call it from `FlattenAndValidateAsync` (keep the existing "Template chain is empty." failure), then add
|
||||
|
||||
```csharp
|
||||
public async Task<Result<FlattenedConfiguration>> PreviewAsync(int templateId, int siteId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var session = CreateSession();
|
||||
var graph = await LoadTemplateGraphAsync(templateId, session, cancellationToken);
|
||||
if (graph is null)
|
||||
return Result<FlattenedConfiguration>.Failure($"Template {templateId} not found or its chain is empty.");
|
||||
|
||||
var dataConnections = await session.GetDataConnectionsAsync(siteId, id => LoadDataConnectionsAsync(id, cancellationToken));
|
||||
var preview = new Instance("(preview)") { TemplateId = templateId, SiteId = siteId };
|
||||
return _flatteningService.Flatten(preview, graph.Value.Chain, graph.Value.CompositionMap, graph.Value.ComposedChains, dataConnections);
|
||||
}
|
||||
```
|
||||
|
||||
(Use the fully-qualified `Commons.Entities.Templates.*` names the file already uses.)
|
||||
|
||||
**Step 4: Run tests**
|
||||
|
||||
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests --filter "FlatteningPipeline"`
|
||||
Expected: all PASS (preview tests + the existing ConnectionBinding / NativeAlarmCapability / FlattenSessionCache suites, which exercise the refactored path).
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ZB.MOM.WW.ScadaBridge.DeploymentManager tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/FlatteningPipelinePreviewTests.cs
|
||||
git commit -m "feat(deploymentmanager): IFlatteningPipeline.PreviewAsync — flatten a template+site without an instance row (create-page bindings)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `CreateBindingPlanner` — Prefill + tail stripping + binding list (pure)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 1, Task 3
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/CreateBindingPlanner.cs`
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/CreateBindingPlannerTests.cs` (create)
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
|
||||
```csharp
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
|
||||
|
||||
public class CreateBindingPlannerTests
|
||||
{
|
||||
private static IReadOnlyList<BindingGroup> Groups() => BindingGroupBuilder.Build(
|
||||
[
|
||||
new BindingRow("Running", "Running", null, null), // (instance)
|
||||
new BindingRow("Left.MoveInFlag", "MoveInFlag", null, null), // bare default → suggest
|
||||
new BindingRow("Left.MoveOutFlag", "MoveOutFlag", null, null),
|
||||
new BindingRow("Recv.Count", "MESReceiver_023.Count", null, null), // prefixed default → from template
|
||||
new BindingRow("Recv.State", "MESReceiver_023.State", null, null),
|
||||
]);
|
||||
|
||||
[Fact]
|
||||
public void Prefill_fills_blank_objects_only()
|
||||
{
|
||||
var inputs = new Dictionary<string, CreateBindingPlanner.GroupInput>
|
||||
{
|
||||
["Left"] = new(0, "Typed_Left"),
|
||||
};
|
||||
var result = CreateBindingPlanner.Prefill(Groups(), inputs, connectionId: 7, rootObject: "Z28063");
|
||||
|
||||
Assert.Equal(new CreateBindingPlanner.GroupInput(7, "Z28063"), result[BindingGroupBuilder.InstanceGroupKey]);
|
||||
Assert.Equal(new CreateBindingPlanner.GroupInput(7, "Typed_Left"), result["Left"]); // typed wins
|
||||
Assert.Equal(new CreateBindingPlanner.GroupInput(7, "MESReceiver_023"), result["Recv"]); // from template
|
||||
Assert.Equal(CreateBindingPlanner.ObjectSource.Typed, CreateBindingPlanner.SourceOf(Groups()[1], result["Left"]));
|
||||
Assert.Equal(CreateBindingPlanner.ObjectSource.FromTemplate, CreateBindingPlanner.SourceOf(Groups()[2], result["Recv"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Prefill_suggests_root_dot_module_for_bare_defaults()
|
||||
{
|
||||
var result = CreateBindingPlanner.Prefill(Groups(), new Dictionary<string, CreateBindingPlanner.GroupInput>(), 7, "Z28063");
|
||||
Assert.Equal("Z28063.Left", result["Left"].Object);
|
||||
Assert.Equal(CreateBindingPlanner.ObjectSource.Suggested, CreateBindingPlanner.SourceOf(Groups()[1], result["Left"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Prefill_with_blank_root_sets_connection_only()
|
||||
{
|
||||
var result = CreateBindingPlanner.Prefill(Groups(), new Dictionary<string, CreateBindingPlanner.GroupInput>(), 7, "");
|
||||
Assert.Equal(new CreateBindingPlanner.GroupInput(7, null), result[BindingGroupBuilder.InstanceGroupKey]);
|
||||
Assert.Equal(new CreateBindingPlanner.GroupInput(7, null), result["Left"]);
|
||||
Assert.Equal(new CreateBindingPlanner.GroupInput(7, "MESReceiver_023"), result["Recv"]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Z28063.Left.MoveInFlag", "Z28063.Left")] // leaf under the group → strip tail
|
||||
[InlineData("Z28063.Left", "Z28063.Left")] // object node → as-is
|
||||
[InlineData("ns=2;s=Plant.Left.MoveOutFlag", "ns=2;s=Plant.Left")]
|
||||
[InlineData("Other.Thing", "Other.Thing")] // no member tail matches → as-is
|
||||
public void ObjectFromPick_strips_member_tail(string picked, string expected)
|
||||
=> Assert.Equal(expected, CreateBindingPlanner.ObjectFromPick(Groups()[1], picked));
|
||||
|
||||
[Fact]
|
||||
public void BuildBindings_skips_groups_without_connection_or_object()
|
||||
{
|
||||
var inputs = new Dictionary<string, CreateBindingPlanner.GroupInput>
|
||||
{
|
||||
[BindingGroupBuilder.InstanceGroupKey] = new(7, "Z28063"),
|
||||
["Left"] = new(7, null), // connection, no object → skipped (stays unbound)
|
||||
["Recv"] = new(0, "MESReceiver_023"), // object, no connection → skipped
|
||||
};
|
||||
var bindings = CreateBindingPlanner.BuildBindings(Groups(), inputs);
|
||||
var single = Assert.Single(bindings);
|
||||
Assert.Equal(("Running", 7, "Z28063.Running"), (single.AttributeName, single.DataConnectionId, single.DataSourceReferenceOverride));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildBindings_rewrites_every_member_of_a_bound_group()
|
||||
{
|
||||
var inputs = new Dictionary<string, CreateBindingPlanner.GroupInput> { ["Left"] = new(7, "Z28063.Left") };
|
||||
var bindings = CreateBindingPlanner.BuildBindings(Groups(), inputs);
|
||||
Assert.Equal(2, bindings.Count);
|
||||
Assert.Contains(bindings, b => b.AttributeName == "Left.MoveInFlag" && b.DataSourceReferenceOverride == "Z28063.Left.MoveInFlag");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run to verify failure**
|
||||
|
||||
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter CreateBindingPlannerTests`
|
||||
Expected: build error — `CreateBindingPlanner` not defined.
|
||||
|
||||
**Step 3: Implement**
|
||||
|
||||
```csharp
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Pure logic behind the Create Instance page's Connection Bindings card
|
||||
/// (design 2026-08-21): Prefill from a root object, Object inference from a
|
||||
/// browser pick, and the binding list sent with Create. Groups come from
|
||||
/// <see cref="BindingGroupBuilder.Build"/> over a template+site preview flatten.
|
||||
/// </summary>
|
||||
public static class CreateBindingPlanner
|
||||
{
|
||||
/// <summary>Per-group operator input. <c>ConnectionId</c> 0 = none.</summary>
|
||||
public sealed record GroupInput(int ConnectionId, string? Object);
|
||||
|
||||
public enum ObjectSource { None, Typed, Suggested, FromTemplate }
|
||||
|
||||
/// <summary>
|
||||
/// Sets <paramref name="connectionId"/> on every group and fills BLANK Objects only:
|
||||
/// the instance group gets <paramref name="rootObject"/>; a module group whose
|
||||
/// defaults already share a prefix keeps it (<see cref="BindingGroup.InferredObject"/>);
|
||||
/// otherwise, when a root is given, <c>{root}.{groupKey}</c> is suggested. A typed
|
||||
/// Object is never overwritten.
|
||||
/// </summary>
|
||||
public static IReadOnlyDictionary<string, GroupInput> Prefill(
|
||||
IReadOnlyList<BindingGroup> groups,
|
||||
IReadOnlyDictionary<string, GroupInput> current,
|
||||
int connectionId,
|
||||
string? rootObject)
|
||||
{
|
||||
var root = (rootObject ?? string.Empty).Trim().TrimEnd('.');
|
||||
var result = new Dictionary<string, GroupInput>(StringComparer.Ordinal);
|
||||
foreach (var g in groups)
|
||||
{
|
||||
current.TryGetValue(g.Key, out var existing);
|
||||
var obj = existing?.Object;
|
||||
if (string.IsNullOrWhiteSpace(obj))
|
||||
{
|
||||
obj = g.IsInstanceGroup ? (root.Length == 0 ? null : root)
|
||||
: g.InferredObject is { Length: > 0 } inferred ? inferred
|
||||
: root.Length == 0 ? null
|
||||
: root + "." + g.Key;
|
||||
}
|
||||
result[g.Key] = new GroupInput(connectionId, obj);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Where a group's current Object value came from, for the row's provenance pill.</summary>
|
||||
public static ObjectSource SourceOf(BindingGroup group, GroupInput? input)
|
||||
{
|
||||
var obj = input?.Object?.Trim().TrimEnd('.');
|
||||
if (string.IsNullOrEmpty(obj)) return ObjectSource.None;
|
||||
if (!group.IsInstanceGroup && string.Equals(obj, group.InferredObject, StringComparison.Ordinal)) return ObjectSource.FromTemplate;
|
||||
return input!.Suggested ? ObjectSource.Suggested : ObjectSource.Typed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Object for a browser pick: an Object node is used as-is; a Variable whose id ends
|
||||
/// with <c>.{memberTail}</c> of any member of the group has that tail stripped.
|
||||
/// </summary>
|
||||
public static string ObjectFromPick(BindingGroup group, string pickedNodeId)
|
||||
{
|
||||
foreach (var m in group.Members)
|
||||
{
|
||||
var suffix = "." + m.MemberTail;
|
||||
if (pickedNodeId.EndsWith(suffix, StringComparison.Ordinal) && pickedNodeId.Length > suffix.Length)
|
||||
return pickedNodeId[..^suffix.Length];
|
||||
}
|
||||
return pickedNodeId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The bindings to send with Create: every member of every group that has BOTH a
|
||||
/// connection and a non-blank Object, rewritten via <see cref="BindingGroupBuilder.ApplyObject"/>.
|
||||
/// Groups missing either are left unbound for Configure.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<ConnectionBinding> BuildBindings(
|
||||
IReadOnlyList<BindingGroup> groups,
|
||||
IReadOnlyDictionary<string, GroupInput> inputs)
|
||||
{
|
||||
var list = new List<ConnectionBinding>();
|
||||
foreach (var g in groups)
|
||||
{
|
||||
if (!inputs.TryGetValue(g.Key, out var input) || input.ConnectionId <= 0 || string.IsNullOrWhiteSpace(input.Object))
|
||||
continue;
|
||||
foreach (var c in BindingGroupBuilder.ApplyObject(g, input.ConnectionId, input.Object))
|
||||
list.Add(new ConnectionBinding(c.CanonicalName, c.ConnectionId!.Value, c.Override));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`SourceOf` needs to know whether Prefill produced the value: make `GroupInput` carry a third positional `bool Suggested = false`, and have `Prefill` set `Suggested: true` only on the `root + "." + g.Key` branch. Update the test expectations that construct `GroupInput(7, "Z28063.Left")` accordingly (`new(7, "Z28063.Left", true)` where the suggestion is asserted). Keep the record positional so the tests' `Assert.Equal` on records works.
|
||||
|
||||
**Step 4: Run tests**
|
||||
|
||||
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "CreateBindingPlannerTests|BindingGroupBuilderTests"`
|
||||
Expected: PASS.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/CreateBindingPlanner.cs tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/CreateBindingPlannerTests.cs
|
||||
git commit -m "feat(centralui): CreateBindingPlanner — prefill/tail-strip/binding-list logic for create-time bindings"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `NodeBrowserDialog.SelectableNodeClasses` (additive)
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 1, Task 2
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/NodeBrowserDialog.razor` (parameters ~128-150; `SelectBrowseNode`/`Select` ~356-370; footer prompt ~119)
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/TreeRow.razor` (line 14 `@if (Node.NodeClass == BrowseNodeClass.Variable)`; parameter block ~74)
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/NodeBrowserDialogSelectionTests.cs` (append)
|
||||
|
||||
**Step 1: Write the failing tests** (append to `NodeBrowserDialogSelectionTests`; the fixture's `SearchAsync` stub already returns one Variable — add an Object match to the stub's `Matches` array: `new AddressSpaceMatch(new BrowseNode("ns=2;s=Pump1", "Pump1", BrowseNodeClass.Object, HasChildren: true), Path: "Devices/Pump1")`)
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Object_node_is_not_selectable_by_default()
|
||||
{
|
||||
var cut = Render<NodeBrowserDialog>(p => p.Add(c => c.SiteId, "plant-a").Add(c => c.ConnectionName, "PLC-OPC"));
|
||||
cut.InvokeAsync(() => cut.Instance.ShowAsync("plant-a", "PLC-OPC", null));
|
||||
cut.Render();
|
||||
cut.Find("[data-test=node-search-input]").Input("Pump1");
|
||||
cut.Find("[data-test=node-search-button]").Click();
|
||||
|
||||
// Only the Variable renders a select button; the Object is plain text.
|
||||
Assert.Single(cut.FindAll("[data-test=node-search-result] button.btn-link"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Object_node_is_selectable_when_listed()
|
||||
{
|
||||
BrowseNode? captured = null;
|
||||
var cut = Render<NodeBrowserDialog>(p => p
|
||||
.Add(c => c.SiteId, "plant-a").Add(c => c.ConnectionName, "PLC-OPC")
|
||||
.Add(c => c.SelectableNodeClasses, new[] { BrowseNodeClass.Object, BrowseNodeClass.Variable })
|
||||
.Add(c => c.OnNodeSelected, EventCallback.Factory.Create<BrowseNode>(this, n => captured = n)));
|
||||
cut.InvokeAsync(() => cut.Instance.ShowAsync("plant-a", "PLC-OPC", null));
|
||||
cut.Render();
|
||||
cut.Find("[data-test=node-search-input]").Input("Pump1");
|
||||
cut.Find("[data-test=node-search-button]").Click();
|
||||
var buttons = cut.FindAll("[data-test=node-search-result] button.btn-link");
|
||||
Assert.Equal(2, buttons.Count);
|
||||
buttons.First(b => b.TextContent.Trim() == "Pump1").Click();
|
||||
cut.Find(".modal-footer .btn-primary").Click();
|
||||
|
||||
Assert.Equal("ns=2;s=Pump1", captured!.NodeId);
|
||||
Assert.Equal(BrowseNodeClass.Object, captured.NodeClass);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run to verify failure**
|
||||
|
||||
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter NodeBrowserDialogSelectionTests`
|
||||
Expected: second test fails to build (`SelectableNodeClasses` missing); first test already passes (pin).
|
||||
|
||||
**Step 3: Implement**
|
||||
|
||||
`NodeBrowserDialog.razor`:
|
||||
- Parameter: `[Parameter] public IReadOnlyCollection<BrowseNodeClass> SelectableNodeClasses { get; set; } = new[] { BrowseNodeClass.Variable };` with a doc comment "Additive: node classes the picker accepts. Default Variable-only (every existing caller). The create page passes Object+Variable so a module's container can be chosen."
|
||||
- Helper: `private bool IsSelectable(BrowseNodeClass c) => SelectableNodeClasses.Contains(c);`
|
||||
- `SelectBrowseNode` / `Select`: replace `!= BrowseNodeClass.Variable` guards with `!IsSelectable(node.NodeClass)`.
|
||||
- Search-result markup (~line 55-70): render the `<button>` when `IsSelectable(node.NodeClass)`, else a plain `<span>@node.DisplayName</span>`; when the class is `Object` and selectable, add `<span class="badge bg-body-secondary text-body-secondary border ms-1">object</span>`.
|
||||
- Pass `IsSelectable` to the tree: `<TreeRow ... Selectable="IsSelectable" />` and in the footer prompt, if `SelectableNodeClasses.Contains(BrowseNodeClass.Object)` show `<span class="text-muted small me-auto">Select an object or tag</span>`.
|
||||
|
||||
`TreeRow.razor`:
|
||||
- Add `[Parameter] public Func<BrowseNodeClass, bool>? Selectable { get; set; }` and `private bool IsSelectable => Selectable?.Invoke(Node.NodeClass) ?? Node.NodeClass == BrowseNodeClass.Variable;`
|
||||
- Line 14: `@if (IsSelectable)`; keep the non-selectable branch as today; pass `Selectable="Selectable"` on the recursive `<TreeRow>` (line 52). For an `Object` node that IS selectable, the existing expand toggle must still render — keep the toggle markup outside the `@if`.
|
||||
|
||||
**Step 4: Run tests**
|
||||
|
||||
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "NodeBrowserDialog"`
|
||||
Expected: PASS (selection + search suites).
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/NodeBrowserDialogSelectionTests.cs
|
||||
git commit -m "feat(centralui): NodeBrowserDialog.SelectableNodeClasses — opt-in Object node selection (default unchanged)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Create page — code-behind state + preview load
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (blocked by 1, 2)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor.cs`
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor` (`@code` block: move fields to partial class; add `@inject IFlatteningPipeline FlatteningPipeline`, `@inject ISiteRepository` is already there)
|
||||
|
||||
**Step 1: Implement** (no pure logic here — the testable parts live in Tasks 1-2; this task is wiring)
|
||||
|
||||
`InstanceCreate.razor.cs` (`public partial class InstanceCreate`):
|
||||
|
||||
```csharp
|
||||
// --- 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' @onchange; re-runs the preview.</summary>
|
||||
private async Task OnTemplateOrSiteChangedAsync()
|
||||
{
|
||||
_groups = Array.Empty<BindingGroup>(); _inputs.Clear(); _previewError = null; _rowCount = 0;
|
||||
if (!BindingsCardVisible) return;
|
||||
_previewLoading = true;
|
||||
try
|
||||
{
|
||||
var site = _sites.First(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; }
|
||||
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".
|
||||
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(0, null)) with { ConnectionId = id };
|
||||
private void SetGroupObject(BindingGroup g, string? obj)
|
||||
=> _inputs[g.Key] = (_inputs.GetValueOrDefault(g.Key) ?? new(0, null)) with { Object = obj, Suggested = false };
|
||||
```
|
||||
|
||||
Move the existing `@code` fields (`_sites`, `_templates`, `_allAreas`, `_loading`, `_createName`, `_createTemplateId`, `_createSiteId`, `_createAreaId`, `_formError`) into the partial class so both files see them; leave `OnInitializedAsync`, `CreateInstance`, `GoBack`, `GetCurrentUserAsync` in the `.razor` for now (Task 6 rewrites `CreateInstance`). The Template/Site `<select>`s switch from `@bind` to `value=`/`@onchange` so the preview can run on change:
|
||||
|
||||
```razor
|
||||
<select class="form-select form-select-sm" value="@_createTemplateId"
|
||||
@onchange="async e => { _createTemplateId = int.TryParse(e.Value?.ToString(), out var v) ? v : 0; await OnTemplateOrSiteChangedAsync(); }">
|
||||
```
|
||||
|
||||
(same for Site, which additionally resets `_createAreaId = 0`). Also run `OnTemplateOrSiteChangedAsync()` at the end of `OnInitializedAsync` when `SiteId` came from the query string and a template is preselected (it won't be — keep the call; it no-ops when either is 0).
|
||||
|
||||
**Step 2: Build**
|
||||
|
||||
Run: `dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI`
|
||||
Expected: 0 errors.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor.cs
|
||||
git commit -m "feat(centralui): Create Instance — preview-flatten state + group inputs behind the bindings card"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Create page — Connection Bindings card markup + Browse
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (blocked by 3, 4)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor`
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor.cs` (browser plumbing)
|
||||
- Reference only: `InstanceConfigure.razor` lines 88-175 (group header markup to mirror), `InstanceConfigure.razor.css` (`.binding-panel`, `.binding-group-header` — reuse by copying the two rules into a new `InstanceCreate.razor.css`)
|
||||
|
||||
**Step 1: Markup** — insert after the form card, before the error/buttons (move the Create/Cancel button group into the new card's footer when the card is visible; keep it in the form card when it is not):
|
||||
|
||||
```razor
|
||||
@if (BindingsCardVisible)
|
||||
{
|
||||
<div class="card mb-3 binding-panel" 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">@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" 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">@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" 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" value="@input?.Object"
|
||||
placeholder="object, e.g. @(g.IsInstanceGroup ? "Z28063" : g.Key)"
|
||||
@onchange="e => SetGroupObject(g, e.Value?.ToString())" />
|
||||
<button class="btn btn-outline-primary btn-sm" 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">
|
||||
<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" @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>
|
||||
}
|
||||
<NodeBrowserDialog @ref="_browserRef" SiteId="@(_siteIdentifier ?? "")" ConnectionName="@_browserConnectionName"
|
||||
InitialNodeId="@_browserInitial" SelectableNodeClasses="@ObjectAndVariable" OnSelected="OnBrowserSelected" />
|
||||
```
|
||||
|
||||
Code-behind additions:
|
||||
|
||||
```csharp
|
||||
private static readonly BrowseNodeClass[] ObjectAndVariable = [BrowseNodeClass.Object, BrowseNodeClass.Variable];
|
||||
private NodeBrowserDialog? _browserRef;
|
||||
private string _browserConnectionName = "";
|
||||
private string? _browserInitial;
|
||||
private BindingGroup? _browserTarget; // null = the root picker
|
||||
|
||||
private bool IsBrowsable(int connectionId)
|
||||
{
|
||||
var protocol = _siteConnections.FirstOrDefault(c => c.Id == connectionId)?.Protocol;
|
||||
return string.Equals(protocol, "OpcUa", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(protocol, "MxGateway", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private async Task OpenBrowser(BindingGroup? target)
|
||||
{
|
||||
_browserTarget = target;
|
||||
var connId = target is null ? _rootConnectionId : _inputs.GetValueOrDefault(target.Key)?.ConnectionId ?? 0;
|
||||
_browserConnectionName = _siteConnections.First(c => c.Id == connId).Name;
|
||||
_browserInitial = string.IsNullOrWhiteSpace(_rootObject) ? null : _rootObject;
|
||||
if (_browserRef is not null) await _browserRef.ShowAsync(_siteIdentifier ?? "", _browserConnectionName, _browserInitial);
|
||||
}
|
||||
|
||||
private void OnBrowserSelected(string nodeId)
|
||||
{
|
||||
if (_browserTarget is null) _rootObject = nodeId;
|
||||
else SetGroupObject(_browserTarget, CreateBindingPlanner.ObjectFromPick(_browserTarget, nodeId));
|
||||
}
|
||||
```
|
||||
|
||||
Check `NodeBrowserDialog.ShowAsync`'s exact signature in the dialog (it is `ShowAsync(siteId, connectionName, initialNodeId)` per the Configure page / tests) and the `@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol` for `BrowseNodeClass`.
|
||||
|
||||
**Step 2: Build + existing suites**
|
||||
|
||||
Run: `dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI && dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "NodeBrowserDialog|BindingGroupBuilder|CreateBindingPlanner"`
|
||||
Expected: 0 errors, PASS.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor.cs src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor.css
|
||||
git commit -m "feat(centralui): Create Instance — Connection Bindings card (per-group connection/Object/Browse, Bind-all prefill)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Create → bindings → navigate to Configure; Configure one-shot notice
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** none (blocked by 5)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor` (`CreateInstance`)
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor` (query params + notice above the Connection Bindings panel, ~line 88)
|
||||
|
||||
**Step 1: Rewrite `CreateInstance`** (keep the validation + site-scope re-check as is):
|
||||
|
||||
```csharp
|
||||
try
|
||||
{
|
||||
var user = await GetCurrentUserAsync();
|
||||
var result = await InstanceService.CreateInstanceAsync(
|
||||
_createName.Trim(), _createTemplateId, _createSiteId, _createAreaId == 0 ? null : _createAreaId, user);
|
||||
if (result.IsFailure) { _formError = result.Error; return; }
|
||||
|
||||
var id = result.Value.Id;
|
||||
var bindings = _skipBindings || _previewError is not null
|
||||
? Array.Empty<ConnectionBinding>()
|
||||
: CreateBindingPlanner.BuildBindings(_groups, _inputs);
|
||||
if (bindings.Count == 0) { NavigationManager.NavigateTo($"/deployment/instances/{id}/configure?created=1"); return; }
|
||||
|
||||
// The instance now exists whatever happens next — always land on Configure,
|
||||
// carrying the binding error so the operator is not left on a stale form.
|
||||
var bind = await InstanceService.SetConnectionBindingsAsync(id, bindings, user);
|
||||
var query = bind.IsSuccess
|
||||
? $"created=1&bound={bindings.Count}"
|
||||
: $"created=1&bindError={Uri.EscapeDataString(Truncate(bind.Error, 300))}";
|
||||
NavigationManager.NavigateTo($"/deployment/instances/{id}/configure?{query}");
|
||||
}
|
||||
catch (Exception ex) { _formError = $"Create failed: {ex.Message}"; }
|
||||
```
|
||||
|
||||
with `private static string Truncate(string s, int n) => s.Length <= n ? s : s[..n] + "…";`. Note `SetConnectionBindingsAsync` can throw (network/actor timeout) after the instance exists — wrap just that call in its own try/catch that sets `bindError` to the exception message, so the outer catch only covers create.
|
||||
|
||||
**Step 2: Configure notice** — add `[SupplyParameterFromQuery] public int? Created { get; set; }`, `public int? Bound { get; set; }`, `public string? BindError { get; set; }` and, directly above the `binding-panel` card:
|
||||
|
||||
```razor
|
||||
@if (Created == 1)
|
||||
{
|
||||
<div class="alert @(BindError is null ? "alert-success" : "alert-warning") py-2 small" data-test="configure-created-notice">
|
||||
Instance created.
|
||||
@if (BindError is not null) { <text>Bindings were NOT saved: @BindError — bind the groups below.</text> }
|
||||
else if (Bound is > 0) { <text>@Bound binding(s) saved. Test Bindings, then Deploy.</text> }
|
||||
else { <text>Bind the groups below, then Deploy.</text> }
|
||||
</div>
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Build**
|
||||
|
||||
Run: `dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI`
|
||||
Expected: 0 errors.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor
|
||||
git commit -m "feat(centralui): Create saves bindings (upsert) and lands on Configure with a created/bound/bindError notice"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Live check on the docker rig
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min (plus image build)
|
||||
**Parallelizable with:** Task 8
|
||||
|
||||
**Files:** none (verification). Uses `docker/build.sh` from the worktree, `docker compose -f /Users/dohertj2/Desktop/ScadaBridge/docker/docker-compose.yml up -d --force-recreate`, CLI per `.claude/skills/scadabridge-cluster-ops`.
|
||||
|
||||
**Steps:**
|
||||
1. Build + recreate the rig from the worktree (not `deploy.sh`).
|
||||
2. Pick a composed template on the rig (CLI `template list`; the SoakNotify/gcb test templates from the 2026-08-20 session, or create a 2-module composed template via CLI if none exists).
|
||||
3. In the browser (`http://localhost:9000`, `multi-role`/`password`): `/deployment/instances/create` → choose the template + site-a → the card shows N groups. Set Bind all to the site's OPC UA connection, root `ns=2;s=Demo` (or whatever the rig's OPC UA server exposes — check an existing bound instance's reference on Configure), Prefill, correct one module Object via Browse… selecting an **Object** node, Create.
|
||||
4. Expect: lands on `/deployment/instances/{id}/configure?created=1&bound=N` with the green notice; the Connection Bindings panel shows the same groups bound; `Test Bindings (N)` enabled; `deploy instance` via CLI passes the binding-completeness gate (or reports only the groups deliberately left unbound).
|
||||
5. Also create one instance with **Skip bindings** ticked → Configure shows "Bind the groups below" and every row unbound (today's behaviour preserved).
|
||||
6. Delete the test instances (`instance delete`). Record findings in the plan's status section.
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Docs
|
||||
|
||||
**Classification:** trivial
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 7
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/requirements/Component-CentralUI.md` (Create Instance / Connection Bindings section — add the create-time card, Prefill semantics, Browse object selection, navigate-to-Configure)
|
||||
- Modify: `docs/requirements/Component-DeploymentManager.md` (`IFlatteningPipeline.PreviewAsync`, one paragraph: template+site preview, no validation, no persistence)
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.CLI/README.md` (one line under `instance create`: bindings can be set at creation in the UI; CLI equivalent is `instance set-bindings` / `bind-module`)
|
||||
- Modify: `docs/plans/2026-08-21-create-instance-bindings-design.md` (status → implemented, link to this plan)
|
||||
- Modify: `CLAUDE.md` Templates & Deployment bullet list — one sentence after the grouped-bindings material: "Create Instance page binds module groups at creation via `IFlatteningPipeline.PreviewAsync` (template+site flatten of an unsaved instance); `NodeBrowserDialog.SelectableNodeClasses` lets it pick Object nodes (default stays Variable-only)."
|
||||
|
||||
Commit: `docs: create-time connection bindings (Central UI, DeploymentManager preview flatten, CLI cross-ref)`.
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Solution build + impacted suites
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (blocked by all)
|
||||
|
||||
Run:
|
||||
```bash
|
||||
dotnet build ZB.MOM.WW.ScadaBridge.slnx
|
||||
dotnet test tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests
|
||||
dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests
|
||||
dotnet test tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests --filter Flatten
|
||||
```
|
||||
Expected: 0 errors, all green (the known env-only Playwright E2E project is not in this list). Then `git log --oneline main..HEAD` shows 7 commits; hand off via superpowers-extended-cc:finishing-a-development-branch.
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-08-21-create-instance-bindings.md",
|
||||
"tasks": [
|
||||
{"id": 1, "subject": "Task 1: IFlatteningPipeline.PreviewAsync (DeploymentManager)", "status": "pending"},
|
||||
{"id": 2, "subject": "Task 2: CreateBindingPlanner — Prefill + tail stripping + binding list (pure)", "status": "pending"},
|
||||
{"id": 3, "subject": "Task 3: NodeBrowserDialog.SelectableNodeClasses (additive)", "status": "pending"},
|
||||
{"id": 4, "subject": "Task 4: Create page — code-behind state + preview load", "status": "pending", "blockedBy": [1, 2]},
|
||||
{"id": 5, "subject": "Task 5: Create page — Connection Bindings card markup + Browse", "status": "pending", "blockedBy": [3, 4]},
|
||||
{"id": 6, "subject": "Task 6: Create → bindings → navigate to Configure; Configure one-shot notice", "status": "pending", "blockedBy": [5]},
|
||||
{"id": 7, "subject": "Task 7: Live check on the docker rig", "status": "pending", "blockedBy": [6]},
|
||||
{"id": 8, "subject": "Task 8: Docs", "status": "pending", "blockedBy": [6]},
|
||||
{"id": 9, "subject": "Task 9: Solution build + impacted suites", "status": "pending", "blockedBy": [1,2,3,4,5,6,7,8]}
|
||||
],
|
||||
"lastUpdated": "2026-08-21T00:00:00Z"
|
||||
}
|
||||
Reference in New Issue
Block a user