diff --git a/docs/plans/2026-08-21-template-picker.md.tasks.json b/docs/plans/2026-08-21-template-picker.md.tasks.json
index 29f45041..715cc599 100644
--- a/docs/plans/2026-08-21-template-picker.md.tasks.json
+++ b/docs/plans/2026-08-21-template-picker.md.tasks.json
@@ -1,14 +1,61 @@
{
"planPath": "docs/plans/2026-08-21-template-picker.md",
"tasks": [
- {"id": 1, "subject": "Task 1: TemplatePathFormatter (pure helper)", "status": "pending"},
- {"id": 2, "subject": "Task 2: TemplateFolderTree.RevealKeys (additive)", "status": "pending"},
- {"id": 3, "subject": "Task 3: TemplatePickerDialog component", "status": "pending", "blockedBy": [1, 2]},
- {"id": 4, "subject": "Task 4: Create page — picker field + ?templateId= preselect", "status": "pending", "blockedBy": [3]},
- {"id": 5, "subject": "Task 5: Templates page — New Instance… menu item (Deployer-gated)", "status": "pending"},
- {"id": 6, "subject": "Task 6: Pin the Topology Disable confirm label", "status": "pending"},
- {"id": 7, "subject": "Task 7: Docs", "status": "pending", "blockedBy": [4, 5, 6]},
- {"id": 8, "subject": "Task 8: Solution build + CentralUI test sweep + live rig check", "status": "pending", "blockedBy": [7]}
+ {
+ "id": 1,
+ "subject": "Task 1: TemplatePathFormatter (pure helper)",
+ "status": "completed"
+ },
+ {
+ "id": 2,
+ "subject": "Task 2: TemplateFolderTree.RevealKeys (additive)",
+ "status": "completed"
+ },
+ {
+ "id": 3,
+ "subject": "Task 3: TemplatePickerDialog component",
+ "status": "completed",
+ "blockedBy": [
+ 1,
+ 2
+ ]
+ },
+ {
+ "id": 4,
+ "subject": "Task 4: Create page \u2014 picker field + ?templateId= preselect",
+ "status": "pending",
+ "blockedBy": [
+ 3
+ ]
+ },
+ {
+ "id": 5,
+ "subject": "Task 5: Templates page \u2014 New Instance\u2026 menu item (Deployer-gated)",
+ "status": "pending"
+ },
+ {
+ "id": 6,
+ "subject": "Task 6: Pin the Topology Disable confirm label",
+ "status": "pending"
+ },
+ {
+ "id": 7,
+ "subject": "Task 7: Docs",
+ "status": "pending",
+ "blockedBy": [
+ 4,
+ 5,
+ 6
+ ]
+ },
+ {
+ "id": 8,
+ "subject": "Task 8: Solution build + CentralUI test sweep + live rig check",
+ "status": "pending",
+ "blockedBy": [
+ 7
+ ]
+ }
],
- "lastUpdated": "2026-08-21T12:00:00Z"
-}
+ "lastUpdated": "2026-08-21T00:00:00Z"
+}
\ No newline at end of file
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor
index 34c43e75..93f56781 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor
@@ -9,6 +9,7 @@
@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.Components.Shared
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
@inject ITemplateEngineRepository TemplateEngineRepository
@@ -18,6 +19,7 @@
@inject IFlatteningPipeline FlatteningPipeline
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
+@inject IDialogService Dialog
@@ -39,15 +41,22 @@
-
+
+
+
+
+ @if (_templates.Count == 0)
+ {
+
+ }
+ else if (_templateHint is not null)
+ {
+
@_templateHint
+ }
@@ -228,11 +237,21 @@
@code {
+ ///
Query preselect: the template the operator picked elsewhere (e.g. the Templates tree's "New Instance…").
+ [SupplyParameterFromQuery] public int? TemplateId { get; set; }
[SupplyParameterFromQuery] public int? SiteId { get; set; }
[SupplyParameterFromQuery] public int? AreaId { get; set; }
private List
_sites = new();
private List _templates = new();
+ private List _folders = new();
+ private Dictionary _foldersById = new();
+ private string? _templateHint;
+
+ /// Folder-qualified label shown in the read-only template field; blank when nothing is chosen.
+ private string TemplatePathLabel => _templates.FirstOrDefault(t => t.Id == _createTemplateId) is { } t
+ ? TemplatePathFormatter.Format(t, _foldersById)
+ : string.Empty;
private List _allAreas = new();
private bool _loading = true;
@@ -247,6 +266,13 @@
try
{
_templates = (await TemplateEngineRepository.GetAllTemplatesAsync()).ToList();
+ _folders = (await TemplateEngineRepository.GetAllFoldersAsync()).ToList();
+ _foldersById = _folders.ToDictionary(f => f.Id);
+ if (TemplateId is int tid)
+ {
+ if (_templates.Any(t => t.Id == tid)) _createTemplateId = tid;
+ else _templateHint = $"Template {tid} no longer exists — choose another.";
+ }
// Site scoping (CentralUI-002): a scoped Deployment user may only
// create instances on sites they are permitted on.
_sites = await SiteScope.FilterSitesAsync(await SiteRepository.GetAllSitesAsync());
@@ -268,8 +294,8 @@
}
- // 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.
+ // Query-string preselects (siteId + templateId) make this a real preview
+ // when both are present; it no-ops when either is missing.
await OnTemplateOrSiteChangedAsync();
}
catch (Exception ex)
@@ -353,4 +379,22 @@
// resolved through JwtTokenService rather than a duplicated magic string.
private Task GetCurrentUserAsync()
=> AuthStateProvider.GetCurrentUsernameAsync();
+
+ ///
+ /// Opens the folder-tree template picker. A pick clears any dangling-id hint and
+ /// re-runs the bindings preview; Cancel (or re-picking the same template) is a no-op.
+ ///
+ private async Task OpenTemplatePickerAsync()
+ {
+ if (_templates.Count == 0) return;
+ var picked = await Dialog.ShowAsync(
+ "Choose template",
+ ctx => @,
+ size: "modal-lg");
+ if (picked is null || picked.TemplateId == _createTemplateId) return;
+ _createTemplateId = picked.TemplateId;
+ _templateHint = null;
+ await OnTemplateOrSiteChangedAsync();
+ }
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceCreateTemplateFieldTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceCreateTemplateFieldTests.cs
new file mode 100644
index 00000000..1c0fc3f8
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceCreateTemplateFieldTests.cs
@@ -0,0 +1,215 @@
+using System.Security.Claims;
+using Bunit;
+using Bunit.TestDoubles;
+using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Components.Rendering;
+using Microsoft.AspNetCore.Components.Authorization;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using NSubstitute;
+using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
+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.Interfaces.Services;
+using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
+using ZB.MOM.WW.ScadaBridge.Commons.Types;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
+using ZB.MOM.WW.ScadaBridge.Communication;
+using ZB.MOM.WW.ScadaBridge.DeploymentManager;
+using ZB.MOM.WW.ScadaBridge.Security;
+using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
+using InstanceCreatePage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment.InstanceCreate;
+
+namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
+
+///
+/// bUnit tests for the Create Instance template field: a read-only path box plus a
+/// Choose… button that opens TemplatePickerDialog, and the ?templateId=
+/// query preselect (including the dangling-id hint and the no-templates empty state).
+///
+public class InstanceCreateTemplateFieldTests : BunitContext
+{
+ private readonly ITemplateEngineRepository _repo = Substitute.For();
+ private readonly ISiteRepository _siteRepo = Substitute.For();
+ private readonly IDeploymentManagerRepository _deployRepo = Substitute.For();
+ private readonly IFlatteningPipeline _pipeline = Substitute.For();
+ private readonly IAuditService _audit = Substitute.For();
+ private readonly DialogService _dialog = new();
+
+ public InstanceCreateTemplateFieldTests()
+ {
+ Services.AddSingleton(_repo);
+ Services.AddSingleton(_siteRepo);
+ Services.AddSingleton(_deployRepo);
+ Services.AddSingleton(_pipeline);
+ Services.AddSingleton(_audit);
+ // The Connection Bindings card renders NodeBrowserDialog once a site is chosen.
+ Services.AddSingleton(Substitute.For());
+
+ // DeploymentService / InstanceService have non-mockable concrete deps;
+ // instantiate them directly (same block TopologyPageTests uses).
+ Services.AddSingleton(new CommunicationService(
+ Options.Create(new CommunicationOptions()),
+ NullLogger.Instance));
+ Services.AddSingleton(new OperationLockManager());
+ Services.AddSingleton(Options.Create(new DeploymentManagerOptions
+ {
+ OperationLockTimeout = TimeSpan.FromSeconds(5)
+ }));
+ Services.AddSingleton(Options.Create(new CommunicationOptions()));
+ Services.AddSingleton>(NullLogger.Instance);
+ Services.AddScoped();
+ Services.AddScoped();
+ Services.AddSingleton(
+ new DeploymentStatusNotifier(NullLogger.Instance));
+ Services.AddSingleton(Substitute.For());
+ Services.AddScoped();
+ Services.AddScoped();
+ Services.AddScoped();
+ Services.AddScoped();
+
+ // A real DialogService behind a real DialogHost so Choose… actually opens.
+ Services.AddSingleton(_dialog);
+
+ var claims = new[]
+ {
+ new Claim(JwtTokenService.UsernameClaimType, "tester"),
+ new Claim(JwtTokenService.RoleClaimType, "Deployer")
+ };
+ Services.AddSingleton(
+ new TestAuthStateProvider(new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"))));
+ Services.AddAuthorizationCore();
+
+ JSInterop.Mode = JSRuntimeMode.Loose;
+ }
+
+ /// Folders A → B with template 7 "Deep"; folder Other with template 8 "Elsewhere".
+ private void SeedCatalogue(bool empty = false, bool withSite = false)
+ {
+ var folders = empty
+ ? new List()
+ : new List
+ {
+ new("A") { Id = 1 },
+ new("B") { Id = 2, ParentFolderId = 1 },
+ new("Other") { Id = 3 },
+ };
+ var templates = empty
+ ? new List()
+ : new List
+ {
+ new("Deep") { Id = 7, FolderId = 2 },
+ new("Elsewhere") { Id = 8, FolderId = 3 },
+ };
+
+ _repo.GetAllTemplatesAsync(Arg.Any())
+ .Returns(Task.FromResult>(templates));
+ _repo.GetAllFoldersAsync(Arg.Any())
+ .Returns(Task.FromResult>(folders));
+
+ var sites = withSite
+ ? new List { new("Site A", "site-a") { Id = 1 } }
+ : new List();
+ _siteRepo.GetAllSitesAsync(Arg.Any())
+ .Returns(Task.FromResult>(sites));
+ _siteRepo.GetDataConnectionsBySiteIdAsync(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult>(new List()));
+ _repo.GetAreasBySiteIdAsync(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult>(new List()));
+
+ _pipeline.PreviewAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(Result.Success(new FlattenedConfiguration())));
+ }
+
+ ///
+ /// Renders the page and a DialogHost side by side (as MainLayout does) after seeding
+ /// the query string. [SupplyParameterFromQuery] properties are cascading-only,
+ /// so they cannot be Add()-ed as ordinary parameters — the URL is the only way in.
+ ///
+ private IRenderedComponent RenderPage(int? templateId = null, int? siteId = null)
+ {
+ var query = new List();
+ if (templateId is int t) query.Add($"templateId={t}");
+ if (siteId is int s) query.Add($"siteId={s}");
+ var nav = (BunitNavigationManager)Services.GetRequiredService();
+ nav.NavigateTo("/deployment/instances/create" + (query.Count > 0 ? "?" + string.Join("&", query) : ""));
+ return Render();
+ }
+
+ /// Wrapper that renders InstanceCreate next to a DialogHost, as MainLayout does.
+ public sealed class TestHostWrapper : ComponentBase
+ {
+ protected override void BuildRenderTree(RenderTreeBuilder builder)
+ {
+ builder.OpenComponent(0);
+ builder.CloseComponent();
+ builder.OpenComponent(1);
+ builder.CloseComponent();
+ }
+ }
+
+ private static void ExpandFolder(IRenderedComponent cut, string label) =>
+ cut.FindAll("li[role='treeitem']")
+ .First(li => li.QuerySelector(".tv-label")!.TextContent.Trim() == label)
+ .QuerySelector(".tv-toggle")!.Click();
+
+ [Fact]
+ public void TemplateField_IsReadOnlyWithChooseButton_AndPlaceholder()
+ {
+ SeedCatalogue();
+ var cut = RenderPage();
+ var field = cut.Find("input[data-test='create-template']");
+ Assert.True(field.HasAttribute("readonly"));
+ Assert.Equal("Choose a template…", field.GetAttribute("placeholder"));
+ Assert.NotNull(cut.Find("button[data-test='create-template-choose']"));
+ }
+
+ [Fact]
+ public void TemplateIdQuery_PrefillsFieldWithFolderPath()
+ {
+ SeedCatalogue();
+ var cut = RenderPage(templateId: 7);
+ Assert.Equal("A / B / Deep", cut.Find("input[data-test='create-template']").GetAttribute("value"));
+ }
+
+ [Fact]
+ public void UnknownTemplateIdQuery_LeavesFieldEmpty_AndShowsHint()
+ {
+ SeedCatalogue();
+ var cut = RenderPage(templateId: 999);
+ Assert.Equal("", cut.Find("input[data-test='create-template']").GetAttribute("value") ?? "");
+ Assert.Contains("Template 999 no longer exists", cut.Markup);
+ }
+
+ [Fact]
+ public void NoTemplates_DisablesChoose_AndLinksToTemplatesPage()
+ {
+ SeedCatalogue(empty: true);
+ var cut = RenderPage();
+ Assert.True(cut.Find("button[data-test='create-template-choose']").HasAttribute("disabled"));
+ Assert.Contains("/design/templates", cut.Markup);
+ }
+
+ [Fact]
+ public async Task ChoosingInPicker_SetsTemplate_AndRePreviewsBindingsWhenSiteSet()
+ {
+ SeedCatalogue(withSite: true);
+ var cut = RenderPage(siteId: 1);
+
+ cut.Find("button[data-test='create-template-choose']").Click();
+ cut.WaitForAssertion(() => Assert.NotNull(cut.Find("button[data-test='picker-select']")));
+
+ ExpandFolder(cut, "A");
+ ExpandFolder(cut, "B");
+ cut.FindAll(".tv-label").First(l => l.TextContent.Trim() == "Deep").Click();
+ cut.Find("button[data-test='picker-select']").Click();
+
+ cut.WaitForAssertion(() => Assert.Equal(
+ "A / B / Deep", cut.Find("input[data-test='create-template']").GetAttribute("value")));
+ await _pipeline.Received(1).PreviewAsync(7, 1, Arg.Any());
+ }
+}