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);
}