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:
@@ -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"
|
||||
}
|
||||
+55
-11
@@ -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…").</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();
|
||||
}
|
||||
}
|
||||
|
||||
+215
@@ -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>());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user