using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
///
/// A combobox that picks one instance out of a site's area tree: a text field over a
/// dropdown panel hosting across the forest that
/// builds from the site's areas and instances.
///
///
/// It replaces the flat instance <select> on Flow Debugger and Debug View,
/// where a site with a few hundred instances made the list unusable. The panel opens
/// with every area collapsed so the operator drills down; an outside click closes it
/// through a transparent fixed backdrop (no JavaScript, the same pattern
/// uses for its context menu).
///
///
///
/// Typing in the field filters the forest to the instances whose UniqueName contains
/// the text plus their ancestor areas, force-expanded so every match is on screen; clearing
/// the text restores the full forest, collapsed. Enter picks the first instance the
/// operator can actually see (document order, skipping collapsed branches), ArrowDown
/// hands keyboard navigation to the tree's own roving tabindex, and Escape closes
/// without changing the selection. The filter never survives a close or a pick.
///
///
///
/// A parent that swaps / (a site change)
/// closes the panel and re-derives the field text from the current selection, clearing
/// it when the selected instance is not in the new list. A parent that changes
/// (a deep link or a localStorage restore) updates the
/// field text while the panel is closed.
///
///
public partial class InstancePicker
{
///
/// The site's areas, flat and linked by ; null is treated
/// as empty. Compared by REFERENCE: a new list object means "the site changed" and closes
/// the panel, drops the filter and re-derives the field text. Hold a stable reference and
/// only hand the picker a new list when the underlying set actually changed — rebuilding
/// the list on every parent render would close the panel out from under the operator.
///
[Parameter]
public IReadOnlyList? Areas { get; set; }
///
/// The site's instances; null is treated as empty. Compared by REFERENCE on the same
/// contract as — pass a stable reference and only replace it when the
/// underlying set changed.
///
[Parameter]
public IReadOnlyList? Instances { get; set; }
/// Id of the currently selected instance; 0 means nothing is selected.
[Parameter]
public int SelectedInstanceId { get; set; }
/// Raised with the instance id when the operator picks a row.
[Parameter]
public EventCallback SelectedInstanceIdChanged { get; set; }
/// Disables the field and the chevron; the panel can no longer be opened.
[Parameter]
public bool Disabled { get; set; }
///
/// Maps an instance to the text shown on its row and, once picked, in the field.
/// Defaults to . Note that searching always matches
/// on UniqueName (the builder's contract), whatever this renders.
///
[Parameter]
public Func? ItemLabel { get; set; }
///
/// data-test root for the control: {prefix}-picker, -input,
/// -toggle, -panel, -backdrop and -row (instance rows also
/// carry data-instance-id).
///
[Parameter]
public string DataTestPrefix { get; set; } = "instance";
/// Placeholder shown in the field while nothing is selected.
[Parameter]
public string Placeholder { get; set; } = "Search or browse instances…";
private readonly string _idSuffix = Guid.NewGuid().ToString("N")[..8];
private bool _open;
private string _text = string.Empty;
///
/// The name filter applied to the forest. Empty until the search box lands; kept
/// separate from so a selected label never doubles as a filter.
///
private string _filter = string.Empty;
private bool _hasFilter;
private IReadOnlyList _roots = [];
private TreeView? _tree;
/// Bulk expansion the tree owes the current filter, applied after the next render.
private enum PendingTreeAction
{
/// Nothing to do.
None,
/// Open every branch so the filtered matches are all on screen.
ExpandAll,
/// Close every branch, back to the drill-down starting point.
CollapseAll,
}
// The TreeView only exists once the panel has rendered, so keyboard/typing work that
// targets it is deferred to OnAfterRenderAsync through these two flags.
private PendingTreeAction _pendingTreeAction;
private bool _pendingTreeFocus;
// Last parameter values seen, so OnParametersSet can tell a real change from a
// parent that simply re-rendered with the same lists and selection.
private IReadOnlyList? _lastAreas;
private IReadOnlyList? _lastInstances;
private int _lastSelectedId;
private bool _parametersSeen;
private string _inputId => $"ip-{_idSuffix}-input";
private string _panelId => $"ip-{_idSuffix}-panel";
///
protected override void OnParametersSet()
{
var listsChanged = !_parametersSeen
|| !ReferenceEquals(_lastAreas, Areas)
|| !ReferenceEquals(_lastInstances, Instances);
var selectionChanged = !_parametersSeen || _lastSelectedId != SelectedInstanceId;
_parametersSeen = true;
_lastAreas = Areas;
_lastInstances = Instances;
_lastSelectedId = SelectedInstanceId;
if (!listsChanged && !selectionChanged) return;
var selected = CurrentSelection();
if (listsChanged)
{
// A site change: drop any filter, rebuild, and close unconditionally — the panel
// is showing the OLD site's tree, so leaving it open would be showing a lie even
// when the selected instance happens to exist in the new list too.
_filter = string.Empty;
Rebuild();
_open = false;
_text = selected is null ? string.Empty : LabelOf(selected);
}
else if (!_open)
{
_text = selected is null ? string.Empty : LabelOf(selected);
}
}
///
protected override async Task OnAfterRenderAsync(bool firstRender)
{
var action = _pendingTreeAction;
var focus = _pendingTreeFocus;
_pendingTreeAction = PendingTreeAction.None;
_pendingTreeFocus = false;
// The panel may have closed again in the same batch (a pick, an Escape), taking
// the tree with it — there is nothing left to expand or focus.
if (!_open || _tree is null) return;
// ExpandAll/CollapseAll re-render the TREE, not this component, so consuming the
// flag here cannot loop back into another picker render.
switch (action)
{
case PendingTreeAction.ExpandAll:
_tree.ExpandAll();
break;
case PendingTreeAction.CollapseAll:
_tree.CollapseAll();
break;
}
if (focus) await _tree.FocusFirstVisibleAsync();
}
/// The instance points at, or null when unresolved.
private Instance? CurrentSelection() =>
SelectedInstanceId == 0 ? null : Instances?.FirstOrDefault(i => i.Id == SelectedInstanceId);
/// Rebuilds the forest for the current filter.
private void Rebuild()
{
_hasFilter = !string.IsNullOrWhiteSpace(_filter);
_roots = InstanceTreeBuilder.Build(Areas, Instances, _filter);
}
private string LabelOf(Instance instance) => ItemLabel?.Invoke(instance) ?? instance.UniqueName;
private string RowLabel(InstanceTreeNode node) =>
node.Instance is { } instance ? LabelOf(instance) : node.Label;
private void Open()
{
if (Disabled || _open) return;
_open = true;
}
private void Toggle()
{
if (Disabled) return;
if (_open) Close(); else _open = true;
}
private void Close()
{
_open = false;
if (_hasFilter)
{
_filter = string.Empty;
Rebuild();
}
var selected = CurrentSelection();
_text = selected is null ? string.Empty : LabelOf(selected);
}
///
/// Every keystroke retypes the filter: the field text IS the filter while the operator
/// is typing, and the panel opens if it was closed. The rebuilt forest is expanded when
/// something is being searched for and collapsed back to the drill-down view when the
/// text is cleared — both after the next render, once the tree exists.
///
private void OnInput(ChangeEventArgs e)
{
if (Disabled) return;
_text = e.Value?.ToString() ?? string.Empty;
_filter = _text;
_open = true;
Rebuild();
_pendingTreeAction = _hasFilter ? PendingTreeAction.ExpandAll : PendingTreeAction.CollapseAll;
}
///
/// The field's keyboard surface. Deliberately no preventDefault: Blazor's flag is
/// all-or-nothing per element, and neither default is harmful here (Enter in a text input
/// outside a form does nothing; ArrowDown just moves the caret) — while suppressing Tab
/// would break getting out of the control.
///
private Task OnInputKeyDown(KeyboardEventArgs e)
{
if (Disabled) return Task.CompletedTask;
switch (e.Key)
{
case "Enter" when _open:
return FirstVisibleInstance() is { } instance ? SelectAsync(instance) : Task.CompletedTask;
case "Escape" when _open:
Close();
break;
case "ArrowDown":
_open = true;
_pendingTreeFocus = true;
break;
}
return Task.CompletedTask;
}
///
/// The first instance leaf in document order among the rows the operator can actually
/// see — a collapsed area's contents are skipped, matching what Enter appears to pick.
/// With a filter active every branch is expanded, so this degrades to plain document
/// order over the filtered forest.
///
private Instance? FirstVisibleInstance() => FirstVisibleInstance(_roots);
private Instance? FirstVisibleInstance(IReadOnlyList nodes)
{
foreach (var node in nodes)
{
if (node.Instance is { } instance) return instance;
if (!node.HasChildren) continue;
// Before the tree's first render the filter's pending ExpandAll decides visibility.
var expanded = _tree?.IsExpanded(node.Key) ?? _hasFilter;
if (expanded && FirstVisibleInstance(node.Children) is { } hit) return hit;
}
return null;
}
private Task OnRowClickAsync(InstanceTreeNode node)
{
if (node.Instance is { } instance) return SelectAsync(instance);
_tree?.ToggleExpanded(node.Key);
return Task.CompletedTask;
}
private async Task SelectAsync(Instance instance)
{
_text = LabelOf(instance);
_open = false;
if (_hasFilter)
{
_filter = string.Empty;
Rebuild();
}
await SelectedInstanceIdChanged.InvokeAsync(instance.Id);
}
}