< Summary

Information
Class: Chronicis.Client.Services.Tree.TreeUiState
Assembly: Chronicis.Client
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Client/Services/Tree/TreeUiState.cs
Line coverage
87%
Covered lines: 130
Uncovered lines: 19
Coverable lines: 149
Total lines: 462
Line coverage: 87.2%
Branch coverage
86%
Covered branches: 97
Total branches: 112
Branch coverage: 86.6%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
get_SelectedNodeId()100%11100%
get_SearchQuery()100%11100%
get_IsSearchActive()100%11100%
get_PendingSelectionId()100%11100%
get_ExpandedNodeIds()100%11100%
SetNodeIndex(...)100%11100%
Reset()100%11100%
ClearPendingSelection()100%210%
ConsumePendingSelection()100%11100%
ExpandNode(...)100%44100%
CollapseNode(...)50%4483.33%
ToggleNode(...)66.66%6680%
SelectNode(...)100%2222100%
ExpandPathToAndSelect(...)85.71%141490%
BuildPathToNode(...)100%88100%
ClearSelection()100%66100%
SetSearchQuery(...)50%44100%
ClearSearch()100%22100%
ApplySearchFilter()100%1818100%
AddNodeAndAncestors(...)90%101085.71%
GetExpandedNodeIds()100%210%
RestoreExpandedNodes(...)100%66100%
RestoreExpandedNodesPreserving(...)0%4260%
SaveExpandedStateAsync()100%1150%
RestoreExpandedStateFromStorageAsync()100%2262.5%
GetExpandedNodesStorageKey()100%11100%

File(s)

/home/runner/work/chronicis/chronicis/src/Chronicis.Client/Services/Tree/TreeUiState.cs

#LineLine coverage
 1using Blazored.LocalStorage;
 2using Chronicis.Client.Models;
 3
 4namespace Chronicis.Client.Services.Tree;
 5
 6/// <summary>
 7/// Manages UI-related state for the tree: expansion, selection, search filtering, and persistence.
 8/// This component does not perform any API calls - it operates purely on the in-memory node index.
 9/// </summary>
 10internal sealed class TreeUiState
 11{
 12    private readonly ILocalStorageService _localStorage;
 13    private readonly ILogger _logger;
 14
 15    private const string ExpandedNodesStorageKey = "chronicis_expanded_nodes";
 16
 17    // Shared node index (owned by TreeStateService, passed in)
 2518    private TreeNodeIndex _nodeIndex = new();
 19
 20    // UI State
 2521    private readonly HashSet<Guid> _expandedNodeIds = new();
 22    private Guid? _selectedNodeId;
 23    private Guid? _pendingSelectionId;
 2524    private string _searchQuery = string.Empty;
 25
 2526    public TreeUiState(ILocalStorageService localStorage, ILogger logger)
 27    {
 2528        _localStorage = localStorage;
 2529        _logger = logger;
 2530    }
 31
 32    // ============================================
 33    // State Properties
 34    // ============================================
 35
 36    /// <summary>
 37    /// Gets the currently selected node ID.
 38    /// </summary>
 639    public Guid? SelectedNodeId => _selectedNodeId;
 40
 41    /// <summary>
 42    /// Gets the current search query.
 43    /// </summary>
 244    public string SearchQuery => _searchQuery;
 45
 46    /// <summary>
 47    /// Gets whether a search filter is currently active.
 48    /// </summary>
 1149    public bool IsSearchActive => !string.IsNullOrWhiteSpace(_searchQuery);
 50
 51    /// <summary>
 52    /// Gets the pending selection ID (for selection before tree is initialized).
 53    /// </summary>
 254    public Guid? PendingSelectionId => _pendingSelectionId;
 55
 56    /// <summary>
 57    /// Gets the set of expanded node IDs.
 58    /// </summary>
 759    public IReadOnlySet<Guid> ExpandedNodeIds => _expandedNodeIds;
 60
 61    // ============================================
 62    // Initialization
 63    // ============================================
 64
 65    /// <summary>
 66    /// Sets the node index reference. Called after tree is built.
 67    /// </summary>
 68    public void SetNodeIndex(TreeNodeIndex nodeIndex)
 69    {
 2270        _nodeIndex = nodeIndex;
 2271    }
 72
 73    /// <summary>
 74    /// Resets UI state (called before tree rebuild).
 75    /// </summary>
 76    public void Reset()
 77    {
 178        _expandedNodeIds.Clear();
 179        _selectedNodeId = null;
 180        _searchQuery = string.Empty;
 181    }
 82
 83    /// <summary>
 84    /// Clears the pending selection.
 85    /// </summary>
 86    public void ClearPendingSelection()
 87    {
 088        _pendingSelectionId = null;
 089    }
 90
 91    /// <summary>
 92    /// Checks if there's a pending selection and returns it, clearing the pending state.
 93    /// </summary>
 94    public Guid? ConsumePendingSelection()
 95    {
 196        var pending = _pendingSelectionId;
 197        _pendingSelectionId = null;
 198        return pending;
 99    }
 100
 101    // ============================================
 102    // Node Operations
 103    // ============================================
 104
 105    /// <summary>
 106    /// Expands a node to show its children.
 107    /// </summary>
 108    /// <returns>True if the node was found and expanded.</returns>
 109    public bool ExpandNode(Guid nodeId)
 110    {
 10111        if (_nodeIndex.TryGetNode(nodeId, out var node) && node != null)
 112        {
 9113            node.IsExpanded = true;
 9114            _expandedNodeIds.Add(nodeId);
 9115            _ = SaveExpandedStateAsync();
 9116            return true;
 117        }
 1118        return false;
 119    }
 120
 121    /// <summary>
 122    /// Collapses a node to hide its children.
 123    /// </summary>
 124    /// <returns>True if the node was found and collapsed.</returns>
 125    public bool CollapseNode(Guid nodeId)
 126    {
 3127        if (_nodeIndex.TryGetNode(nodeId, out var node) && node != null)
 128        {
 3129            node.IsExpanded = false;
 3130            _expandedNodeIds.Remove(nodeId);
 3131            _ = SaveExpandedStateAsync();
 3132            return true;
 133        }
 0134        return false;
 135    }
 136
 137    /// <summary>
 138    /// Toggles a node's expanded state.
 139    /// </summary>
 140    /// <returns>True if the node was found and toggled.</returns>
 141    public bool ToggleNode(Guid nodeId)
 142    {
 4143        if (_nodeIndex.TryGetNode(nodeId, out var node) && node != null)
 144        {
 4145            if (node.IsExpanded)
 1146                return CollapseNode(nodeId);
 147            else
 3148                return ExpandNode(nodeId);
 149        }
 0150        return false;
 151    }
 152
 153    /// <summary>
 154    /// Selects a node. For virtual groups, toggles expansion instead.
 155    /// </summary>
 156    /// <returns>True if selection changed or node was toggled.</returns>
 157    public bool SelectNode(Guid nodeId)
 158    {
 159        // Deselect previous
 10160        if (_selectedNodeId.HasValue && _nodeIndex.TryGetNode(_selectedNodeId.Value, out var previousNode) && previousNo
 161        {
 2162            previousNode.IsSelected = false;
 163        }
 164
 10165        if (_nodeIndex.TryGetNode(nodeId, out var node) && node != null)
 166        {
 167            // Selectable node types: Article, World, Campaign, Arc
 9168            if (node.NodeType == TreeNodeType.Article ||
 9169                node.NodeType == TreeNodeType.World ||
 9170                node.NodeType == TreeNodeType.Campaign ||
 9171                node.NodeType == TreeNodeType.Arc)
 172            {
 8173                node.IsSelected = true;
 8174                _selectedNodeId = nodeId;
 175
 176                // Auto-expand if node has children
 8177                if (node.HasChildren && !node.IsExpanded)
 178                {
 1179                    node.IsExpanded = true;
 1180                    _expandedNodeIds.Add(nodeId);
 1181                    _ = SaveExpandedStateAsync();
 182                }
 8183                return true;
 184            }
 185            else
 186            {
 187                // For virtual groups, just toggle expand
 1188                ToggleNode(nodeId);
 1189                _selectedNodeId = null;
 1190                return true;
 191            }
 192        }
 193        else
 194        {
 1195            _selectedNodeId = null;
 1196            return false;
 197        }
 198    }
 199
 200    /// <summary>
 201    /// Expands the path to a node, collapses nodes not in the path, and selects the target.
 202    /// If tree is not initialized, stores as pending selection.
 203    /// </summary>
 204    /// <param name="nodeId">The node to navigate to.</param>
 205    /// <param name="isInitialized">Whether the tree is initialized.</param>
 206    /// <returns>True if the operation was performed, false if deferred as pending.</returns>
 207    public bool ExpandPathToAndSelect(Guid nodeId, bool isInitialized)
 208    {
 4209        if (!isInitialized)
 210        {
 2211            _pendingSelectionId = nodeId;
 2212            _selectedNodeId = nodeId;
 2213            return false;
 214        }
 215
 2216        if (!_nodeIndex.TryGetNode(nodeId, out var targetNode) || targetNode == null)
 217        {
 0218            _logger.LogWarning("ExpandPathToAndSelect: Node {NodeId} not found", nodeId);
 0219            return false;
 220        }
 221
 222        // Build path from root to target
 2223        var path = BuildPathToNode(targetNode);
 6224        var pathNodeIds = new HashSet<Guid>(path.Select(n => n.Id));
 225
 226        // Collapse all nodes that are NOT in the path to the target
 14227        foreach (var node in _nodeIndex.AllNodes)
 228        {
 5229            if (node.IsExpanded && !pathNodeIds.Contains(node.Id))
 230            {
 1231                node.IsExpanded = false;
 1232                _expandedNodeIds.Remove(node.Id);
 233            }
 234        }
 235
 236        // Expand all ancestors in the path (except the target itself)
 8237        for (int i = 0; i < path.Count - 1; i++)
 238        {
 2239            var node = path[i];
 2240            node.IsExpanded = true;
 2241            _expandedNodeIds.Add(node.Id);
 242        }
 243
 244        // Select the target
 2245        SelectNode(nodeId);
 246
 2247        _ = SaveExpandedStateAsync();
 2248        return true;
 249    }
 250
 251    /// <summary>
 252    /// Builds the path from root to the given node.
 253    /// </summary>
 254    private List<TreeNode> BuildPathToNode(TreeNode targetNode)
 255    {
 2256        var path = new List<TreeNode>();
 2257        var current = targetNode;
 258
 6259        while (current != null)
 260        {
 4261            path.Insert(0, current);
 262
 4263            if (current.ParentId.HasValue && _nodeIndex.TryGetNode(current.ParentId.Value, out var parent) && parent != 
 264            {
 2265                current = parent;
 266            }
 267            else
 268            {
 269                // Check if this node is a child of a world/group node
 2270                current = _nodeIndex.FindParentNode(current);
 271            }
 272        }
 273
 2274        return path;
 275    }
 276
 277    /// <summary>
 278    /// Clears the current selection.
 279    /// </summary>
 280    public void ClearSelection()
 281    {
 1282        if (_selectedNodeId.HasValue && _nodeIndex.TryGetNode(_selectedNodeId.Value, out var node) && node != null)
 283        {
 1284            node.IsSelected = false;
 285        }
 1286        _selectedNodeId = null;
 1287    }
 288
 289    // ============================================
 290    // Search/Filter
 291    // ============================================
 292
 293    /// <summary>
 294    /// Sets the search query and filters the tree.
 295    /// </summary>
 296    public void SetSearchQuery(string query)
 297    {
 7298        _searchQuery = query?.Trim() ?? string.Empty;
 7299        ApplySearchFilter();
 7300    }
 301
 302    /// <summary>
 303    /// Clears the search filter and makes all nodes visible.
 304    /// </summary>
 305    public void ClearSearch()
 306    {
 2307        _searchQuery = string.Empty;
 308
 10309        foreach (var node in _nodeIndex.AllNodes)
 310        {
 3311            node.IsVisible = true;
 312        }
 2313    }
 314
 315    /// <summary>
 316    /// Applies the current search filter to the tree.
 317    /// </summary>
 318    public void ApplySearchFilter()
 319    {
 7320        if (!IsSearchActive)
 321        {
 1322            ClearSearch();
 1323            return;
 324        }
 325
 6326        var searchLower = _searchQuery.ToLowerInvariant();
 6327        var matchingNodeIds = new HashSet<Guid>();
 328
 329        // Find all matching nodes (articles only for search)
 30330        foreach (var node in _nodeIndex.AllNodes)
 331        {
 9332            if (node.NodeType == TreeNodeType.Article &&
 9333                node.Title.Contains(searchLower, StringComparison.OrdinalIgnoreCase))
 334            {
 6335                AddNodeAndAncestors(node, matchingNodeIds);
 336            }
 337        }
 338
 339        // Set visibility
 30340        foreach (var node in _nodeIndex.AllNodes)
 341        {
 9342            node.IsVisible = matchingNodeIds.Contains(node.Id);
 343        }
 344
 345        // Expand nodes that have visible children
 26346        foreach (var nodeId in matchingNodeIds)
 347        {
 7348            if (_nodeIndex.TryGetNode(nodeId, out var node) && node != null)
 349            {
 8350                if (node.Children.Any(c => c.IsVisible))
 351                {
 1352                    node.IsExpanded = true;
 1353                    _expandedNodeIds.Add(nodeId);
 354                }
 355            }
 356        }
 6357    }
 358
 359    /// <summary>
 360    /// Recursively adds a node and all its ancestors to the set.
 361    /// </summary>
 362    private void AddNodeAndAncestors(TreeNode node, HashSet<Guid> set)
 363    {
 7364        set.Add(node.Id);
 365
 366        // Add direct parent
 7367        if (node.ParentId.HasValue && _nodeIndex.TryGetNode(node.ParentId.Value, out var parent) && parent != null)
 368        {
 1369            AddNodeAndAncestors(parent, set);
 370        }
 371
 372        // Also find the containing node (for virtual groups, etc.)
 7373        var container = _nodeIndex.FindParentNode(node);
 7374        if (container != null && !set.Contains(container.Id))
 375        {
 0376            AddNodeAndAncestors(container, set);
 377        }
 7378    }
 379
 380    // ============================================
 381    // Persistence
 382    // ============================================
 383
 384    /// <summary>
 385    /// Gets the IDs of all currently expanded nodes.
 386    /// </summary>
 0387    public IReadOnlySet<Guid> GetExpandedNodeIds() => _expandedNodeIds;
 388
 389    /// <summary>
 390    /// Restores expanded state from a set of node IDs.
 391    /// </summary>
 392    public void RestoreExpandedNodes(IEnumerable<Guid> nodeIds)
 393    {
 2394        _expandedNodeIds.Clear();
 395
 10396        foreach (var nodeId in nodeIds)
 397        {
 3398            if (_nodeIndex.TryGetNode(nodeId, out var node) && node != null)
 399            {
 2400                node.IsExpanded = true;
 2401                _expandedNodeIds.Add(nodeId);
 402            }
 403        }
 2404    }
 405
 406    /// <summary>
 407    /// Restores expanded state from a previously saved set, preserving additional expanded nodes.
 408    /// Used during refresh to maintain state.
 409    /// </summary>
 410    public void RestoreExpandedNodesPreserving(IEnumerable<Guid> nodeIds)
 411    {
 0412        foreach (var nodeId in nodeIds)
 413        {
 0414            if (_nodeIndex.TryGetNode(nodeId, out var node) && node != null)
 415            {
 0416                node.IsExpanded = true;
 0417                _expandedNodeIds.Add(nodeId);
 418            }
 419        }
 0420    }
 421
 422    /// <summary>
 423    /// Saves the current expanded state to localStorage.
 424    /// </summary>
 425    public async Task SaveExpandedStateAsync()
 426    {
 427        try
 428        {
 15429            await _localStorage.SetItemAsync(ExpandedNodesStorageKey, _expandedNodeIds.ToList());
 15430        }
 0431        catch (Exception ex)
 432        {
 0433            _logger.LogWarning(ex, "Failed to save expanded state");
 0434        }
 15435    }
 436
 437    /// <summary>
 438    /// Restores expanded state from localStorage.
 439    /// </summary>
 440    public async Task RestoreExpandedStateFromStorageAsync()
 441    {
 442        try
 443        {
 2444            var savedIds = await _localStorage.GetItemAsync<List<Guid>>(ExpandedNodesStorageKey);
 445
 2446            if (savedIds != null)
 447            {
 1448                RestoreExpandedNodes(savedIds);
 449            }
 2450        }
 0451        catch (Exception ex)
 452        {
 0453            _logger.LogWarning(ex, "Failed to restore expanded state");
 0454        }
 2455    }
 456
 457    /// <summary>
 458    /// Gets the localStorage key used for expanded nodes persistence.
 459    /// Exposed for testing purposes.
 460    /// </summary>
 1461    public static string GetExpandedNodesStorageKey() => ExpandedNodesStorageKey;
 462}