feat(centralui): TemplatePickerDialog — folder-tree template picker hosted by IDialogService

Claude-Session: https://claude.ai/code/session_01G2GHPnbbKGvyyfbr2XBRVH
This commit is contained in:
Joseph Doherty
2026-08-21 07:08:37 -04:00
parent 8df6bcb6e0
commit 4bd2651925
2 changed files with 274 additions and 0 deletions
@@ -0,0 +1,138 @@
@*
Body component for the Create Instance template picker, hosted by
IDialogService.ShowAsync. Wraps the shared TemplateFolderTree (the same tree
the Templates page renders) with its own search box. Templates are the only
selectable nodes — folders just toggle — and a double-click selects. Closes
with TemplatePickResult, a record so Cancel (null) is distinguishable from a
pick, the same contract MoveTemplateDialog uses.
Selection is tracked here rather than through TreeView's SelectedKey: in
Single mode TemplateFolderTree forwards only the Checkbox-mode SelectedKeys
pair, so the row highlight has to come from this component's own NodeContent.
*@
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
<div class="d-flex gap-2 align-items-center mb-2">
<div class="input-group input-group-sm">
<input type="text" class="form-control" data-test="picker-search" placeholder="Search templates..."
value="@_search" @oninput="OnSearchInput" />
@if (!string.IsNullOrEmpty(_search))
{
<button class="btn btn-outline-secondary" type="button" title="Clear search"
data-test="picker-search-clear" @onclick="() => _search = string.Empty">✕</button>
}
</div>
<button class="btn btn-sm btn-outline-secondary" type="button" title="Expand all folders"
@onclick="() => _tree?.ExpandAll()"><i class="bi bi-arrows-expand"></i></button>
<button class="btn btn-sm btn-outline-secondary" type="button" title="Collapse all folders"
@onclick="() => _tree?.CollapseAll()"><i class="bi bi-arrows-collapse"></i></button>
</div>
<div class="border rounded p-1" style="max-height: 50vh; min-height: 160px; overflow-y: auto;">
<TemplateFolderTree @ref="_tree"
Folders="Folders" Templates="Templates"
SelectionMode="TreeViewSelectionMode.Single"
Filter="@_search"
RevealKeys="_revealKeys">
<NodeContent Context="node">
<span class="tv-glyph">
<i class="bi @(node.Kind == TemplateTreeNodeKind.Folder ? "bi-folder" : "bi-file-earmark-text")"></i>
</span>
<span class="tv-label @LabelClass(node)" title="@node.Name"
@onclick="() => OnNodeClick(node)"
@ondblclick="() => OnNodeDoubleClick(node)">@node.Name</span>
@if (node.Kind == TemplateTreeNodeKind.Folder && node.Children.Count > 0)
{
<span class="tv-meta">
<span class="badge rounded-pill bg-secondary-subtle text-secondary-emphasis">@node.Children.Count</span>
</span>
}
</NodeContent>
<EmptyContent><span class="text-muted fst-italic">No templates match.</span></EmptyContent>
</TemplateFolderTree>
</div>
<div class="modal-footer px-0 pb-0 mt-3 d-flex justify-content-between">
<span class="small text-muted" data-test="picker-status">
@if (_selectedId > 0 && _templatesById.TryGetValue(_selectedId, out var sel))
{
<text>Selected: <span class="text-body">@TemplatePathFormatter.Format(sel, _foldersById)</span></text>
}
else
{
<text>Select a template</text>
}
</span>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-secondary" type="button" data-test="picker-cancel"
@onclick="() => Context.Cancel()">Cancel</button>
<button class="btn btn-primary" type="button" data-test="picker-select"
disabled="@(_selectedId <= 0)" @onclick="Submit">Select</button>
</div>
</div>
@code {
/// <summary>Supplied by <c>IDialogService.ShowAsync</c>; closes/cancels the modal.</summary>
[Parameter] public DialogContext<TemplatePickResult> Context { get; set; } = default!;
/// <summary>Folder catalogue backing the tree.</summary>
[Parameter] public IReadOnlyList<TemplateFolder> Folders { get; set; } = Array.Empty<TemplateFolder>();
/// <summary>Template catalogue backing the tree.</summary>
[Parameter] public IReadOnlyList<Template> Templates { get; set; } = Array.Empty<Template>();
/// <summary>Template to open on: preselected, highlighted, and revealed in the tree.</summary>
[Parameter] public int? InitialTemplateId { get; set; }
private TemplateFolderTree? _tree;
private string _search = string.Empty;
private int _selectedId;
private string[]? _revealKeys;
private Dictionary<int, Template> _templatesById = new();
private Dictionary<int, TemplateFolder> _foldersById = new();
protected override void OnParametersSet()
{
_templatesById = Templates.ToDictionary(t => t.Id);
_foldersById = Folders.ToDictionary(f => f.Id);
if (_selectedId == 0 && InitialTemplateId is int id && _templatesById.ContainsKey(id))
{
_selectedId = id;
_revealKeys = new[] { $"t:{id}" };
}
}
private void OnSearchInput(ChangeEventArgs e) => _search = e.Value?.ToString() ?? string.Empty;
private string LabelClass(TemplateTreeNode node)
{
if (node.Kind == TemplateTreeNodeKind.Folder) return "fw-semibold";
return node.Kind == TemplateTreeNodeKind.Template && node.Id == _selectedId
? "text-primary fw-semibold"
: string.Empty;
}
private void OnNodeClick(TemplateTreeNode node)
{
// Folders and composition slots are structure, not pickable targets.
if (node.Kind != TemplateTreeNodeKind.Template) return;
_selectedId = node.Id;
}
private void OnNodeDoubleClick(TemplateTreeNode node)
{
if (node.Kind != TemplateTreeNodeKind.Template) return;
_selectedId = node.Id;
Submit();
}
private void Submit()
{
if (_selectedId > 0) Context.Close(new TemplatePickResult(_selectedId));
}
/// <summary>Resolved result of the picker; <c>null</c> from ShowAsync means Cancel.</summary>
/// <param name="TemplateId">Id of the template the operator picked.</param>
public sealed record TemplatePickResult(int TemplateId);
}
@@ -0,0 +1,136 @@
using Bunit;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// <summary>
/// bUnit tests for the Create Instance template picker: templates are the only
/// selectable nodes, Select stays disabled until one is picked, double-click
/// selects, Cancel resolves null, and an InitialTemplateId opens the tree on
/// that template.
/// </summary>
public class TemplatePickerDialogTests : BunitContext
{
private readonly DialogService _service = new();
public TemplatePickerDialogTests()
{
Services.AddSingleton<IDialogService>(_service);
JSInterop.Mode = JSRuntimeMode.Loose;
}
private static (List<TemplateFolder>, List<Template>) Catalogue()
{
var a = new TemplateFolder("A") { Id = 1 };
var b = new TemplateFolder("B") { Id = 2, ParentFolderId = 1 };
var other = new TemplateFolder("Other") { Id = 3 };
var t7 = new Template("Deep") { Id = 7, FolderId = 2 };
var t8 = new Template("Elsewhere") { Id = 8, FolderId = 3 };
return (new() { a, b, other }, new() { t7, t8 });
}
private async Task<(IRenderedComponent<DialogHost> cut, Task<TemplatePickerDialog.TemplatePickResult?> pending)> Open(int? initial = null)
{
var (f, t) = Catalogue();
var cut = Render<DialogHost>();
Task<TemplatePickerDialog.TemplatePickResult?> pending = null!;
// Block body, not an expression body: an expression-bodied lambda returning the
// pending Task binds to InvokeAsync(Func<Task>) and would await the dialog itself.
await cut.InvokeAsync(() =>
{
pending = _service.ShowAsync<TemplatePickerDialog.TemplatePickResult>(
"Choose template",
ctx => b =>
{
b.OpenComponent<TemplatePickerDialog>(0);
b.AddAttribute(1, nameof(TemplatePickerDialog.Context), ctx);
b.AddAttribute(2, nameof(TemplatePickerDialog.Folders), f);
b.AddAttribute(3, nameof(TemplatePickerDialog.Templates), t);
b.AddAttribute(4, nameof(TemplatePickerDialog.InitialTemplateId), initial);
b.CloseComponent();
},
size: "modal-lg");
});
cut.Render();
return (cut, pending);
}
private static void ExpandFolder(IRenderedComponent<DialogHost> cut, string label) =>
cut.FindAll("li[role='treeitem']")
.First(li => li.QuerySelector(".tv-label")!.TextContent.Trim() == label)
.QuerySelector(".tv-toggle")!.Click();
private static AngleSharp.Dom.IElement Label(IRenderedComponent<DialogHost> cut, string text) =>
cut.FindAll(".tv-label").First(l => l.TextContent.Trim() == text);
[Fact]
public async Task Select_IsDisabled_UntilATemplateIsClicked()
{
var (cut, _) = await Open();
Assert.True(cut.Find("button[data-test='picker-select']").HasAttribute("disabled"));
Assert.Contains("Select a template", cut.Find("[data-test='picker-status']").TextContent);
}
[Fact]
public async Task ClickingTemplate_ThenSelect_ResolvesItsId()
{
var (cut, pending) = await Open();
ExpandFolder(cut, "A");
ExpandFolder(cut, "B");
Label(cut, "Deep").Click();
Assert.False(cut.Find("button[data-test='picker-select']").HasAttribute("disabled"));
Assert.Contains("A / B / Deep", cut.Find("[data-test='picker-status']").TextContent);
cut.Find("button[data-test='picker-select']").Click();
Assert.Equal(7, (await pending)!.TemplateId);
}
[Fact]
public async Task DoubleClickingTemplate_Resolves()
{
var (cut, pending) = await Open();
ExpandFolder(cut, "A");
ExpandFolder(cut, "B");
Label(cut, "Deep").DoubleClick();
Assert.Equal(7, (await pending)!.TemplateId);
}
[Fact]
public async Task ClickingFolder_SelectsNothing()
{
var (cut, _) = await Open();
Label(cut, "A").Click();
Assert.True(cut.Find("button[data-test='picker-select']").HasAttribute("disabled"));
}
[Fact]
public async Task Cancel_ResolvesNull()
{
var (cut, pending) = await Open();
cut.Find("button[data-test='picker-cancel']").Click();
Assert.Null(await pending);
}
[Fact]
public async Task InitialTemplateId_IsRevealedAndHighlighted()
{
var (cut, _) = await Open(initial: 7);
Assert.Contains("Deep", cut.Markup);
Assert.DoesNotContain("Elsewhere", cut.Markup);
Assert.False(cut.Find("button[data-test='picker-select']").HasAttribute("disabled"));
Assert.Contains("A / B / Deep", cut.Find("[data-test='picker-status']").TextContent);
Assert.Contains("text-primary", Label(cut, "Deep").ClassName);
}
[Fact]
public async Task Search_FiltersTree()
{
var (cut, _) = await Open();
cut.Find("input[data-test='picker-search']").Input("Else");
Assert.Contains("Elsewhere", cut.Markup);
Assert.DoesNotContain("Deep", cut.Markup);
}
}