< 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
100%
Covered lines: 155
Uncovered lines: 0
Coverable lines: 155
Total lines: 495
Line coverage: 100%
Branch coverage
100%
Covered branches: 128
Total branches: 128
Branch coverage: 100%
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%11100%
ConsumePendingSelection()100%11100%
ExpandNode(...)100%44100%
CollapseNode(...)100%44100%
ToggleNode(...)100%66100%
SelectNode(...)100%2828100%
ExpandPathToAndSelect(...)100%2020100%
BuildPathToNode(...)100%1414100%
ClearSelection()100%66100%
SetSearchQuery(...)100%44100%
ClearSearch()100%22100%
ApplySearchFilter()100%1818100%
AddNodeAndAncestors(...)100%1010100%
GetExpandedNodeIds()100%11100%
RestoreExpandedNodes(...)100%66100%
RestoreExpandedNodesPreserving(...)100%66100%
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)
 5618    private TreeNodeIndex _nodeIndex = new();
 19
 20    // UI State
 5621    private readonly HashSet<Guid> _expandedNodeIds = new();
 22    private Guid? _selectedNodeId;
 23    private Guid? _pendingSelectionId;
 5624    private string _searchQuery = string.Empty;
 25
 26    public TreeUiState(ILocalStorageService localStorage, ILogger logger)
 27    {
 5628        _localStorage = localStorage;
 5629        _logger = logger;
 5630    }
 31
 32    // ============================================
 33    // State Properties
 34    // ============================================
 35
 36    /// <summary>
 37    /// Gets the currently selected node ID.
 38    /// </summary>
 2039    public Guid? SelectedNodeId => _selectedNodeId;
 40
 41    /// <summary>
 42    /// Gets the current search query.
 43    /// </summary>
 544    public string SearchQuery => _searchQuery;
 45
 46    /// <summary>
 47    /// Gets whether a search filter is currently active.
 48    /// </summary>
 2649    public bool IsSearchActive => !string.IsNullOrWhiteSpace(_searchQuery);
 50
 51    /// <summary>
 52    /// Gets the pending selection ID (for selection before tree is initialized).
 53    /// </summary>
 354    public Guid? PendingSelectionId => _pendingSelectionId;
 55
 56    /// <summary>
 57    /// Gets the set of expanded node IDs.
 58    /// </summary>
 1559    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    {
 5770        _nodeIndex = nodeIndex;
 5771    }
 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    {
 188        _pendingSelectionId = null;
 189    }
 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    {
 1596        var pending = _pendingSelectionId;
 1597        _pendingSelectionId = null;
 1598        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    {
 15111        if (_nodeIndex.TryGetNode(nodeId, out var node) && node != null)
 112        {
 14113            node.IsExpanded = true;
 14114            _expandedNodeIds.Add(nodeId);
 14115            _ = SaveExpandedStateAsync();
 14116            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    {
 5127        if (_nodeIndex.TryGetNode(nodeId, out var node) && node != null)
 128        {
 4129            node.IsExpanded = false;
 4130            _expandedNodeIds.Remove(nodeId);
 4131            _ = SaveExpandedStateAsync();
 4132            return true;
 133        }
 1134        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    {
 6143        if (_nodeIndex.TryGetNode(nodeId, out var node) && node != null)
 144        {
 5145            if (node.IsExpanded)
 1146                return CollapseNode(nodeId);
 147            else
 4148                return ExpandNode(nodeId);
 149        }
 1150        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
 24160        if (_selectedNodeId.HasValue && _nodeIndex.TryGetNode(_selectedNodeId.Value, out var previousNode) && previousNo
 161        {
 5162            previousNode.IsSelected = false;
 163        }
 164
 24165        if (_nodeIndex.TryGetNode(nodeId, out var node) && node != null)
 166        {
 20167            var isMapsVirtualGroup = node.NodeType == TreeNodeType.VirtualGroup &&
 20168                                     node.VirtualGroupType == VirtualGroupType.Maps;
 169
 170            // Selectable node types: Article, World, Campaign, Arc, Session, Map, Maps virtual group
 20171            if (node.NodeType == TreeNodeType.Article ||
 20172                node.NodeType == TreeNodeType.World ||
 20173                node.NodeType == TreeNodeType.Campaign ||
 20174                node.NodeType == TreeNodeType.Arc ||
 20175                node.NodeType == TreeNodeType.Session ||
 20176                node.NodeType == TreeNodeType.Map ||
 20177                isMapsVirtualGroup)
 178            {
 19179                node.IsSelected = true;
 19180                _selectedNodeId = nodeId;
 181
 182                // Auto-expand if node has children
 19183                if (node.HasChildren && !node.IsExpanded)
 184                {
 3185                    node.IsExpanded = true;
 3186                    _expandedNodeIds.Add(nodeId);
 3187                    _ = SaveExpandedStateAsync();
 188                }
 19189                return true;
 190            }
 191            else
 192            {
 193                // For virtual groups, just toggle expand
 1194                ToggleNode(nodeId);
 1195                _selectedNodeId = null;
 1196                return true;
 197            }
 198        }
 199        else
 200        {
 4201            _selectedNodeId = null;
 4202            return false;
 203        }
 204    }
 205
 206    /// <summary>
 207    /// Expands the path to a node, collapses nodes not in the path, and selects the target.
 208    /// If tree is not initialized, stores as pending selection.
 209    /// </summary>
 210    /// <param name="nodeId">The node to navigate to.</param>
 211    /// <param name="isInitialized">Whether the tree is initialized.</param>
 212    /// <returns>True if the operation was performed, false if deferred as pending.</returns>
 213    public bool ExpandPathToAndSelect(Guid nodeId, bool isInitialized)
 214    {
 13215        if (!isInitialized)
 216        {
 4217            _pendingSelectionId = nodeId;
 4218            _selectedNodeId = nodeId;
 4219            return false;
 220        }
 221
 9222        if (!_nodeIndex.TryGetNode(nodeId, out var targetNode) || targetNode == null)
 223        {
 224            // Some content (e.g., tutorial/system articles) is intentionally excluded
 225            // from the navigation tree but still needs to be loadable in the editor.
 3226            if (_selectedNodeId.HasValue &&
 3227                _nodeIndex.TryGetNode(_selectedNodeId.Value, out var previousNode) &&
 3228                previousNode != null)
 229            {
 1230                previousNode.IsSelected = false;
 231            }
 232
 3233            _selectedNodeId = nodeId;
 3234            _logger.LogInformation("ExpandPathToAndSelect: Node {NodeId} not found in tree; selected directly", nodeId);
 3235            return false;
 236        }
 237
 238        // Build path from root to target
 6239        var path = BuildPathToNode(targetNode);
 6240        var pathNodeIds = new HashSet<Guid>(path.Select(n => n.Id));
 241
 242        // Collapse all nodes that are NOT in the path to the target
 72243        foreach (var node in _nodeIndex.AllNodes)
 244        {
 30245            if (node.IsExpanded && !pathNodeIds.Contains(node.Id))
 246            {
 1247                node.IsExpanded = false;
 1248                _expandedNodeIds.Remove(node.Id);
 249            }
 250        }
 251
 252        // Expand all ancestors in the path (except the target itself)
 20253        for (int i = 0; i < path.Count - 1; i++)
 254        {
 4255            var node = path[i];
 4256            node.IsExpanded = true;
 4257            _expandedNodeIds.Add(node.Id);
 258        }
 259
 260        // Select the target
 6261        SelectNode(nodeId);
 262
 6263        _ = SaveExpandedStateAsync();
 6264        return true;
 265    }
 266
 267    /// <summary>
 268    /// Builds the path from root to the given node.
 269    /// </summary>
 270    private List<TreeNode> BuildPathToNode(TreeNode targetNode)
 271    {
 6272        var path = new List<TreeNode>();
 6273        var current = targetNode;
 6274        var visitedNodes = new HashSet<TreeNode>();
 275
 16276        while (current != null)
 277        {
 11278            if (!visitedNodes.Add(current))
 279            {
 1280                _logger.LogWarning(
 1281                    "Detected cycle while building tree path for node {NodeId}. This can happen when legacy Session and 
 1282                    targetNode.Id);
 1283                break;
 284            }
 285
 10286            path.Insert(0, current);
 287
 10288            TreeNode? next = null;
 289
 10290            if (current.ParentId.HasValue &&
 10291                _nodeIndex.TryGetNode(current.ParentId.Value, out var parent) &&
 10292                parent != null &&
 10293                !ReferenceEquals(parent, current))
 294            {
 2295                next = parent;
 296            }
 297
 298            // Duplicate IDs (legacy Session article + Session entity) can cause the index lookup
 299            // to return the current node instead of its structural parent. Fall back to tree traversal.
 10300            next ??= _nodeIndex.FindParentNode(current);
 301
 10302            current = next;
 303        }
 304
 6305        return path;
 306    }
 307
 308    /// <summary>
 309    /// Clears the current selection.
 310    /// </summary>
 311    public void ClearSelection()
 312    {
 3313        if (_selectedNodeId.HasValue && _nodeIndex.TryGetNode(_selectedNodeId.Value, out var node) && node != null)
 314        {
 2315            node.IsSelected = false;
 316        }
 3317        _selectedNodeId = null;
 3318    }
 319
 320    // ============================================
 321    // Search/Filter
 322    // ============================================
 323
 324    /// <summary>
 325    /// Sets the search query and filters the tree.
 326    /// </summary>
 327    public void SetSearchQuery(string query)
 328    {
 13329        _searchQuery = query?.Trim() ?? string.Empty;
 13330        ApplySearchFilter();
 13331    }
 332
 333    /// <summary>
 334    /// Clears the search filter and makes all nodes visible.
 335    /// </summary>
 336    public void ClearSearch()
 337    {
 4338        _searchQuery = string.Empty;
 339
 32340        foreach (var node in _nodeIndex.AllNodes)
 341        {
 12342            node.IsVisible = true;
 343        }
 4344    }
 345
 346    /// <summary>
 347    /// Applies the current search filter to the tree.
 348    /// </summary>
 349    public void ApplySearchFilter()
 350    {
 14351        if (!IsSearchActive)
 352        {
 2353            ClearSearch();
 2354            return;
 355        }
 356
 12357        var searchLower = _searchQuery.ToLowerInvariant();
 12358        var matchingNodeIds = new HashSet<Guid>();
 359
 360        // Find all matching nodes (articles + sessions)
 100361        foreach (var node in _nodeIndex.AllNodes)
 362        {
 38363            if ((node.NodeType == TreeNodeType.Article || node.NodeType == TreeNodeType.Session) &&
 38364                node.Title.Contains(searchLower, StringComparison.OrdinalIgnoreCase))
 365            {
 11366                AddNodeAndAncestors(node, matchingNodeIds);
 367            }
 368        }
 369
 370        // Set visibility
 100371        foreach (var node in _nodeIndex.AllNodes)
 372        {
 38373            node.IsVisible = matchingNodeIds.Contains(node.Id);
 374        }
 375
 376        // Expand nodes that have visible children
 100377        foreach (var node in _nodeIndex.AllNodes)
 378        {
 38379            if (!matchingNodeIds.Contains(node.Id))
 380            {
 381                continue;
 382            }
 383
 18384            if (node.Children.Any(c => c.IsVisible))
 385            {
 7386                node.IsExpanded = true;
 7387                _expandedNodeIds.Add(node.Id);
 388            }
 389        }
 12390    }
 391
 392    /// <summary>
 393    /// Recursively adds a node and all its ancestors to the set.
 394    /// </summary>
 395    private void AddNodeAndAncestors(TreeNode node, HashSet<Guid> set)
 396    {
 18397        set.Add(node.Id);
 398
 399        // Add direct parent
 18400        if (node.ParentId.HasValue && _nodeIndex.TryGetNode(node.ParentId.Value, out var parent) && parent != null)
 401        {
 2402            AddNodeAndAncestors(parent, set);
 403        }
 404
 405        // Also find the containing node (for virtual groups, etc.)
 18406        var container = _nodeIndex.FindParentNode(node);
 18407        if (container != null && !set.Contains(container.Id))
 408        {
 5409            AddNodeAndAncestors(container, set);
 410        }
 18411    }
 412
 413    // ============================================
 414    // Persistence
 415    // ============================================
 416
 417    /// <summary>
 418    /// Gets the IDs of all currently expanded nodes.
 419    /// </summary>
 1420    public IReadOnlySet<Guid> GetExpandedNodeIds() => _expandedNodeIds;
 421
 422    /// <summary>
 423    /// Restores expanded state from a set of node IDs.
 424    /// </summary>
 425    public void RestoreExpandedNodes(IEnumerable<Guid> nodeIds)
 426    {
 17427        _expandedNodeIds.Clear();
 428
 42429        foreach (var nodeId in nodeIds)
 430        {
 4431            if (_nodeIndex.TryGetNode(nodeId, out var node) && node != null)
 432            {
 3433                node.IsExpanded = true;
 3434                _expandedNodeIds.Add(nodeId);
 435            }
 436        }
 17437    }
 438
 439    /// <summary>
 440    /// Restores expanded state from a previously saved set, preserving additional expanded nodes.
 441    /// Used during refresh to maintain state.
 442    /// </summary>
 443    public void RestoreExpandedNodesPreserving(IEnumerable<Guid> nodeIds)
 444    {
 22445        foreach (var nodeId in nodeIds)
 446        {
 4447            if (_nodeIndex.TryGetNode(nodeId, out var node) && node != null)
 448            {
 2449                node.IsExpanded = true;
 2450                _expandedNodeIds.Add(nodeId);
 451            }
 452        }
 7453    }
 454
 455    /// <summary>
 456    /// Saves the current expanded state to localStorage.
 457    /// </summary>
 458    public async Task SaveExpandedStateAsync()
 459    {
 460        try
 461        {
 462            await _localStorage.SetItemAsync(ExpandedNodesStorageKey, _expandedNodeIds.ToList());
 463        }
 464        catch (Exception ex)
 465        {
 466            _logger.LogWarning(ex, "Failed to save expanded state");
 467        }
 468    }
 469
 470    /// <summary>
 471    /// Restores expanded state from localStorage.
 472    /// </summary>
 473    public async Task RestoreExpandedStateFromStorageAsync()
 474    {
 475        try
 476        {
 477            var savedIds = await _localStorage.GetItemAsync<List<Guid>>(ExpandedNodesStorageKey);
 478
 479            if (savedIds != null)
 480            {
 481                RestoreExpandedNodes(savedIds);
 482            }
 483        }
 484        catch (Exception ex)
 485        {
 486            _logger.LogWarning(ex, "Failed to restore expanded state");
 487        }
 488    }
 489
 490    /// <summary>
 491    /// Gets the localStorage key used for expanded nodes persistence.
 492    /// Exposed for testing purposes.
 493    /// </summary>
 1494    public static string GetExpandedNodesStorageKey() => ExpandedNodesStorageKey;
 495}