feat(centralui): Create Instance picks its template from the folder tree (+ ?templateId preselect)

Claude-Session: https://claude.ai/code/session_01G2GHPnbbKGvyyfbr2XBRVH
This commit is contained in:
Joseph Doherty
2026-08-21 07:42:14 -04:00
parent 4bd2651925
commit 5b4a27b23e
3 changed files with 327 additions and 21 deletions
@@ -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"
}
@@ -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
<div class="container-fluid mt-3">
<div class="d-flex align-items-center mb-3">
@@ -39,15 +41,22 @@
</div>
<div class="mb-2">
<label class="form-label small">Template</label>
<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)
{
<option value="@t.Id">@t.Name</option>
}
</select>
<div class="input-group input-group-sm">
<input type="text" class="form-control" data-test="create-template" readonly
placeholder="Choose a template…" value="@TemplatePathLabel"
style="cursor: pointer; background-color: var(--bs-body-bg);"
@onclick="OpenTemplatePickerAsync" />
<button class="btn btn-outline-secondary" type="button" data-test="create-template-choose"
disabled="@(_templates.Count == 0)" @onclick="OpenTemplatePickerAsync">Choose…</button>
</div>
@if (_templates.Count == 0)
{
<div class="form-text">No templates exist yet — <a href="/design/templates">create one on the Templates page</a>.</div>
}
else if (_templateHint is not null)
{
<div class="form-text text-warning-emphasis">@_templateHint</div>
}
</div>
<div class="mb-2">
<label class="form-label small">Site</label>
@@ -228,11 +237,21 @@
</div>
@code {
/// <summary>Query preselect: the template the operator picked elsewhere (e.g. the Templates tree's "New Instance&#8230;").</summary>
[SupplyParameterFromQuery] public int? TemplateId { get; set; }
[SupplyParameterFromQuery] public int? SiteId { get; set; }
[SupplyParameterFromQuery] public int? AreaId { get; set; }
private List<Site> _sites = new();
private List<Template> _templates = new();
private List<TemplateFolder> _folders = new();
private Dictionary<int, TemplateFolder> _foldersById = new();
private string? _templateHint;
/// <summary>Folder-qualified label shown in the read-only template field; blank when nothing is chosen.</summary>
private string TemplatePathLabel => _templates.FirstOrDefault(t => t.Id == _createTemplateId) is { } t
? TemplatePathFormatter.Format(t, _foldersById)
: string.Empty;
private List<Area> _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<string> GetCurrentUserAsync()
=> AuthStateProvider.GetCurrentUsernameAsync();
/// <summary>
/// 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.
/// </summary>
private async Task OpenTemplatePickerAsync()
{
if (_templates.Count == 0) return;
var picked = await Dialog.ShowAsync<TemplatePickerDialog.TemplatePickResult>(
"Choose template",
ctx => @<TemplatePickerDialog Context="ctx" Folders="_folders" Templates="_templates"
InitialTemplateId="@(_createTemplateId > 0 ? _createTemplateId : null)" />,
size: "modal-lg");
if (picked is null || picked.TemplateId == _createTemplateId) return;
_createTemplateId = picked.TemplateId;
_templateHint = null;
await OnTemplateOrSiteChangedAsync();
}
}
@@ -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;
/// <summary>
/// bUnit tests for the Create Instance template field: a read-only path box plus a
/// Choose… button that opens <c>TemplatePickerDialog</c>, and the <c>?templateId=</c>
/// query preselect (including the dangling-id hint and the no-templates empty state).
/// </summary>
public class InstanceCreateTemplateFieldTests : BunitContext
{
private readonly ITemplateEngineRepository _repo = Substitute.For<ITemplateEngineRepository>();
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
private readonly IDeploymentManagerRepository _deployRepo = Substitute.For<IDeploymentManagerRepository>();
private readonly IFlatteningPipeline _pipeline = Substitute.For<IFlatteningPipeline>();
private readonly IAuditService _audit = Substitute.For<IAuditService>();
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<ZB.MOM.WW.ScadaBridge.CentralUI.Services.IBrowseService>());
// DeploymentService / InstanceService have non-mockable concrete deps;
// instantiate them directly (same block TopologyPageTests uses).
Services.AddSingleton(new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.Instance));
Services.AddSingleton(new OperationLockManager());
Services.AddSingleton(Options.Create(new DeploymentManagerOptions
{
OperationLockTimeout = TimeSpan.FromSeconds(5)
}));
Services.AddSingleton(Options.Create(new CommunicationOptions()));
Services.AddSingleton<ILogger<DeploymentService>>(NullLogger<DeploymentService>.Instance);
Services.AddScoped<ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening.DiffService>();
Services.AddScoped<ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening.RevisionHashService>();
Services.AddSingleton<IDeploymentStatusNotifier>(
new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance));
Services.AddSingleton(Substitute.For<IStaleInstanceProbe>());
Services.AddScoped<DeploymentService>();
Services.AddScoped<AreaService>();
Services.AddScoped<InstanceService>();
Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();
// A real DialogService behind a real DialogHost so Choose… actually opens.
Services.AddSingleton<IDialogService>(_dialog);
var claims = new[]
{
new Claim(JwtTokenService.UsernameClaimType, "tester"),
new Claim(JwtTokenService.RoleClaimType, "Deployer")
};
Services.AddSingleton<AuthenticationStateProvider>(
new TestAuthStateProvider(new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"))));
Services.AddAuthorizationCore();
JSInterop.Mode = JSRuntimeMode.Loose;
}
/// <summary>Folders A → B with template 7 "Deep"; folder Other with template 8 "Elsewhere".</summary>
private void SeedCatalogue(bool empty = false, bool withSite = false)
{
var folders = empty
? new List<TemplateFolder>()
: new List<TemplateFolder>
{
new("A") { Id = 1 },
new("B") { Id = 2, ParentFolderId = 1 },
new("Other") { Id = 3 },
};
var templates = empty
? new List<Template>()
: new List<Template>
{
new("Deep") { Id = 7, FolderId = 2 },
new("Elsewhere") { Id = 8, FolderId = 3 },
};
_repo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Template>>(templates));
_repo.GetAllFoldersAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<TemplateFolder>>(folders));
var sites = withSite
? new List<Site> { new("Site A", "site-a") { Id = 1 } }
: new List<Site>();
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Site>>(sites));
_siteRepo.GetDataConnectionsBySiteIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<DataConnection>>(new List<DataConnection>()));
_repo.GetAreasBySiteIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Area>>(new List<Area>()));
_pipeline.PreviewAsync(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(Result<FlattenedConfiguration>.Success(new FlattenedConfiguration())));
}
/// <summary>
/// Renders the page and a DialogHost side by side (as MainLayout does) after seeding
/// the query string. <c>[SupplyParameterFromQuery]</c> properties are cascading-only,
/// so they cannot be Add()-ed as ordinary parameters — the URL is the only way in.
/// </summary>
private IRenderedComponent<TestHostWrapper> RenderPage(int? templateId = null, int? siteId = null)
{
var query = new List<string>();
if (templateId is int t) query.Add($"templateId={t}");
if (siteId is int s) query.Add($"siteId={s}");
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
nav.NavigateTo("/deployment/instances/create" + (query.Count > 0 ? "?" + string.Join("&", query) : ""));
return Render<TestHostWrapper>();
}
/// <summary>Wrapper that renders InstanceCreate next to a DialogHost, as MainLayout does.</summary>
public sealed class TestHostWrapper : ComponentBase
{
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenComponent<InstanceCreatePage>(0);
builder.CloseComponent();
builder.OpenComponent<DialogHost>(1);
builder.CloseComponent();
}
}
private static void ExpandFolder(IRenderedComponent<TestHostWrapper> 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<CancellationToken>());
}
}