Skip to content

Commit 1702aeb

Browse files
feat: add global tree actions to FancyTreeTable
updated readme
1 parent c544629 commit 1702aeb

3 files changed

Lines changed: 230 additions & 22 deletions

File tree

‎Components/FancyTreeTable.razor‎

Lines changed: 141 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,27 +6,34 @@
66
}
77

88
<div class="fancy-table-topbar d-flex flex-wrap align-items-center justify-content-between gap-2 mb-2">
9-
@if (ShouldRenderPageSizeSelector)
10-
{
11-
<div class="fancy-page-size">
12-
@if (PageSizeTemplate is not null)
13-
{
14-
@PageSizeTemplate(pagedNodes.Context)
15-
}
16-
else
17-
{
18-
<label class="d-inline-flex align-items-center gap-2 small text-muted">
19-
<span>Rows per page</span>
20-
<select class="form-select form-select-sm w-auto" value="@PaginationPageSize" @onchange="OnPageSizeChanged">
21-
@foreach (int option in EffectivePageSizeOptions)
22-
{
23-
<option value="@option">@(option == 0 ? "All" : option.ToString())</option>
24-
}
25-
</select>
26-
</label>
27-
}
28-
</div>
29-
}
9+
<div class="d-flex flex-wrap align-items-center gap-2">
10+
@if (TreeActionsTemplate is not null)
11+
{
12+
@TreeActionsTemplate(TreeContext)
13+
}
14+
15+
@if (ShouldRenderPageSizeSelector)
16+
{
17+
<div class="fancy-page-size">
18+
@if (PageSizeTemplate is not null)
19+
{
20+
@PageSizeTemplate(pagedNodes.Context)
21+
}
22+
else
23+
{
24+
<label class="d-inline-flex align-items-center gap-2 small text-muted">
25+
<span>Rows per page</span>
26+
<select class="form-select form-select-sm w-auto" value="@PaginationPageSize" @onchange="OnPageSizeChanged">
27+
@foreach (int option in EffectivePageSizeOptions)
28+
{
29+
<option value="@option">@(option == 0 ? "All" : option.ToString())</option>
30+
}
31+
</select>
32+
</label>
33+
}
34+
</div>
35+
}
36+
</div>
3037

3138
@if (ToolbarTemplate is not null)
3239
{
@@ -244,6 +251,9 @@
244251
[Parameter]
245252
public RenderFragment<FancyPaginationContext<TItem>>? PageSizeTemplate { get; set; }
246253

254+
[Parameter]
255+
public RenderFragment<FancyTreeTableContext<TItem>>? TreeActionsTemplate { get; set; }
256+
247257
private readonly HashSet<string> HiddenColumns = new(StringComparer.Ordinal);
248258
private readonly Dictionary<TItem, bool> ExpandedStates = new();
249259
private readonly List<TreeNodeState<TItem>> RootNodes = [];
@@ -263,6 +273,7 @@
263273
private bool IsPaginationActive => PaginationEnabled && PaginationPageSize > 0;
264274
private bool ShouldRenderPageSizeSelector => PaginationEnabled && ShowPageSizeSelector;
265275
private int PaginationPageSize => paginationPageSize > 0 ? paginationPageSize : 0;
276+
private FancyTreeTableContext<TItem> TreeContext => BuildTreeContext();
266277

267278
private string CurrentFilter =>
268279
string.IsNullOrWhiteSpace(AppliedSearchText) ? SearchText : AppliedSearchText;
@@ -457,10 +468,32 @@
457468
private Task ToggleNodeAsync(TreeNodeState<TItem> node)
458469
{
459470
node.IsExpanded = !node.IsExpanded;
460-
ExpandedStates[node.Item] = node.IsExpanded;
471+
if (node.IsExpanded)
472+
{
473+
ExpandedStates[node.Item] = true;
474+
}
475+
else
476+
{
477+
ExpandedStates.Remove(node.Item);
478+
}
479+
461480
return Task.CompletedTask;
462481
}
463482

483+
public async Task ExpandAllAsync()
484+
{
485+
ExpandedStates.Clear();
486+
SetExpandedStateForAllNodes(expanded: true);
487+
await InvokeAsync(StateHasChanged);
488+
}
489+
490+
public async Task CollapseAllAsync()
491+
{
492+
ExpandedStates.Clear();
493+
SetExpandedStateForAllNodes(expanded: false);
494+
await InvokeAsync(StateHasChanged);
495+
}
496+
464497
private void HandleSearchKeyPress(KeyboardEventArgs e)
465498
{
466499
if (e.Key == "Enter")
@@ -534,6 +567,85 @@
534567
setPageSizeAsync: SetPageSizeAsync);
535568
}
536569

570+
private FancyTreeTableContext<TItem> BuildTreeContext()
571+
{
572+
TreeNodeStatistics stats = GetTreeNodeStatistics();
573+
574+
return new FancyTreeTableContext<TItem>(
575+
rootItemCount: RootNodes.Count,
576+
visibleNodeCount: BuildVisibleNodes().Count,
577+
expandedNodeCount: stats.ExpandedNodeCount,
578+
hasExpandableNodes: stats.ExpandableNodeCount > 0,
579+
canExpandAny: stats.CollapsedExpandableNodeCount > 0,
580+
canCollapseAny: stats.ExpandedNodeCount > 0,
581+
expandAllAsync: ExpandAllAsync,
582+
collapseAllAsync: CollapseAllAsync);
583+
}
584+
585+
private void SetExpandedStateForAllNodes(bool expanded)
586+
{
587+
foreach (TreeNodeState<TItem> rootNode in RootNodes)
588+
{
589+
SetExpandedStateRecursive(rootNode, expanded);
590+
}
591+
}
592+
593+
private void SetExpandedStateRecursive(TreeNodeState<TItem> node, bool expanded)
594+
{
595+
if (node.HasChildren)
596+
{
597+
node.IsExpanded = expanded;
598+
599+
if (expanded)
600+
{
601+
ExpandedStates[node.Item] = true;
602+
}
603+
}
604+
else
605+
{
606+
node.IsExpanded = false;
607+
}
608+
609+
foreach (TreeNodeState<TItem> child in node.Children)
610+
{
611+
SetExpandedStateRecursive(child, expanded);
612+
}
613+
}
614+
615+
private TreeNodeStatistics GetTreeNodeStatistics()
616+
{
617+
TreeNodeStatistics stats = new();
618+
619+
foreach (TreeNodeState<TItem> rootNode in RootNodes)
620+
{
621+
CollectTreeNodeStatistics(rootNode, stats);
622+
}
623+
624+
return stats;
625+
}
626+
627+
private static void CollectTreeNodeStatistics(TreeNodeState<TItem> node, TreeNodeStatistics stats)
628+
{
629+
if (node.HasChildren)
630+
{
631+
stats.ExpandableNodeCount++;
632+
633+
if (node.IsExpanded)
634+
{
635+
stats.ExpandedNodeCount++;
636+
}
637+
else
638+
{
639+
stats.CollapsedExpandableNodeCount++;
640+
}
641+
}
642+
643+
foreach (TreeNodeState<TItem> child in node.Children)
644+
{
645+
CollectTreeNodeStatistics(child, stats);
646+
}
647+
}
648+
537649
private IReadOnlyList<TreeNodeState<TItem>> ApplyPagination(
538650
IReadOnlyList<TreeNodeState<TItem>> items,
539651
FancyPaginationContext<TItem> paginationContext)
@@ -638,6 +750,13 @@
638750
public required FancyPaginationContext<TItem> Context { get; init; }
639751
}
640752

753+
private sealed class TreeNodeStatistics
754+
{
755+
public int ExpandableNodeCount { get; set; }
756+
public int ExpandedNodeCount { get; set; }
757+
public int CollapsedExpandableNodeCount { get; set; }
758+
}
759+
641760
public void Dispose() => debounceTimer?.Dispose();
642761
}
643762

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
public sealed class FancyTreeTableContext<TItem>
2+
{
3+
private readonly Func<Task> expandAllAsync;
4+
private readonly Func<Task> collapseAllAsync;
5+
6+
public FancyTreeTableContext(
7+
int rootItemCount,
8+
int visibleNodeCount,
9+
int expandedNodeCount,
10+
bool hasExpandableNodes,
11+
bool canExpandAny,
12+
bool canCollapseAny,
13+
Func<Task> expandAllAsync,
14+
Func<Task> collapseAllAsync)
15+
{
16+
RootItemCount = rootItemCount;
17+
VisibleNodeCount = visibleNodeCount;
18+
ExpandedNodeCount = expandedNodeCount;
19+
HasExpandableNodes = hasExpandableNodes;
20+
CanExpandAny = canExpandAny;
21+
CanCollapseAny = canCollapseAny;
22+
this.expandAllAsync = expandAllAsync;
23+
this.collapseAllAsync = collapseAllAsync;
24+
}
25+
26+
public int RootItemCount { get; }
27+
public int VisibleNodeCount { get; }
28+
public int ExpandedNodeCount { get; }
29+
public bool HasExpandableNodes { get; }
30+
public bool CanExpandAny { get; }
31+
public bool CanCollapseAny { get; }
32+
33+
public Task ExpandAll() => expandAllAsync();
34+
public Task CollapseAll() => collapseAllAsync();
35+
}

‎README.md‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ https://www.nuget.org/packages/SoPro.FancyTable
3636

3737
### Hierarchical data
3838
- Expand/collapse in `FancyTreeTable`
39+
- Expand all / collapse all via `TreeActionsTemplate` or `@ref`
3940
- Shared column model across flat and tree tables
4041
- Search keeps matching nodes and their ancestors visible
4142
- Configurable child lookup via `ChildItemsSelector`
@@ -89,6 +90,7 @@ https://www.nuget.org/packages/SoPro.FancyTable
8990
| `HasChildrenSelector` | `Func<TItem, bool>?` | Optional optimization to indicate whether a node should render an expand/collapse toggle |
9091
| `ExpandLabel` | `string` | Accessible label for collapsed nodes (default: `"Expand"`) |
9192
| `CollapseLabel` | `string` | Accessible label for expanded nodes (default: `"Collapse"`) |
93+
| `TreeActionsTemplate` | `RenderFragment<FancyTreeTableContext<TItem>>?` | Renders custom tree-level actions such as expand/collapse all |
9294

9395
## Row Context
9496

@@ -110,6 +112,26 @@ https://www.nuget.org/packages/SoPro.FancyTable
110112

111113
For flat tables, tree-specific values are neutral: `IsTreeNode = false`, `Depth = 0`, `HasChildren = false`, and `ToggleNode` is empty.
112114

115+
## Tree Table Context
116+
117+
`FancyTreeTableContext<TItem>` is passed into `TreeActionsTemplate`.
118+
119+
| Property | Type | Description |
120+
|----------|------|-------------|
121+
| `RootItemCount` | `int` | Number of root items in the tree |
122+
| `VisibleNodeCount` | `int` | Number of currently visible nodes after search and expand/collapse state |
123+
| `ExpandedNodeCount` | `int` | Number of currently expanded nodes that have children |
124+
| `HasExpandableNodes` | `bool` | Whether the tree contains any expandable nodes |
125+
| `CanExpandAny` | `bool` | Whether at least one expandable node is currently collapsed |
126+
| `CanCollapseAny` | `bool` | Whether at least one expandable node is currently expanded |
127+
| `ExpandAll()` | `Task` | Expands the entire tree across all levels |
128+
| `CollapseAll()` | `Task` | Collapses the entire tree across all levels |
129+
130+
`FancyTreeTable<TItem>` also exposes imperative methods for `@ref` usage:
131+
132+
- `ExpandAllAsync()`
133+
- `CollapseAllAsync()`
134+
113135
## Pagination Context
114136

115137
`FancyPaginationContext<TItem>` is passed into `PaginationSummaryTemplate`, `PaginationTemplate`, and `PageSizeTemplate`.
@@ -338,18 +360,22 @@ When pagination is enabled, `FancyTable` paginates after search and sorting. `Cu
338360
@page "/fancy-tree-table-demo"
339361

340362
<FancyTreeTable TItem="RuleRow"
363+
@ref="RuleTable"
341364
Items="Rules"
342365
Columns="RuleColumns"
343366
ChildItemsSelector="row => row.Children"
344367
HasChildrenSelector="row => row.Children.Count > 0"
345368
PaginationEnabled="true"
346369
PageSize="5"
347370
ShowPageSizeSelector="true"
371+
TreeActionsTemplate="RuleTreeActions"
348372
RowTemplate="SectionHeaderTemplate"
349373
RowTemplateSelector="context => context.Item.IsSectionHeader"
350374
SearchPlaceholder="Search rules..." />
351375

352376
@code {
377+
private FancyTreeTable<RuleRow>? RuleTable;
378+
353379
private static readonly RuleRow Rule_11_1 = new("11.1", "Allow HTTPS", "TCP", "443", "Any", "Server-A", "Allow", [], false);
354380
private static readonly RuleRow Rule_11_2 = new("11.2", "Allow DNS", "UDP", "53", "Any", "DNS-1", "Allow", [], false);
355381
private static readonly RuleRow Rule_11_3 = new("11.3", "Block Telnet", "TCP", "23", "Any", "Any", "Deny", [], false);
@@ -385,11 +411,37 @@ When pagination is enabled, `FancyTable` paginates after search and sorting. `Cu
385411
</td>
386412
</tr>;
387413

414+
private RenderFragment<FancyTreeTableContext<RuleRow>> RuleTreeActions => context => @<div class="d-flex flex-wrap gap-2 align-items-center">
415+
<button type="button"
416+
class="btn btn-sm btn-outline-primary"
417+
disabled="@(!context.CanExpandAny)"
418+
@onclick="context.ExpandAll">
419+
Expand All
420+
</button>
421+
<button type="button"
422+
class="btn btn-sm btn-outline-secondary"
423+
disabled="@(!context.CanCollapseAny)"
424+
@onclick="context.CollapseAll">
425+
Collapse All
426+
</button>
427+
<button type="button"
428+
class="btn btn-sm btn-outline-dark"
429+
disabled="@(RuleTable is null)"
430+
@onclick="ExpandViaReferenceAsync">
431+
Expand via @ref
432+
</button>
433+
<span class="small text-muted">
434+
@context.VisibleNodeCount visible rows, @context.ExpandedNodeCount expanded nodes
435+
</span>
436+
</div>;
437+
388438
private void InspectSection(RuleRow row)
389439
{
390440
Console.WriteLine($"Inspect section {row.Name}");
391441
}
392442

443+
private Task ExpandViaReferenceAsync() => RuleTable?.ExpandAllAsync() ?? Task.CompletedTask;
444+
393445
private sealed record RuleRow(
394446
string Number,
395447
string Name,
@@ -403,6 +455,8 @@ When pagination is enabled, `FancyTable` paginates after search and sorting. `Cu
403455
}
404456
```
405457

458+
Global expand/collapse actions always operate on the full tree, not just on the current page or currently visible search results.
459+
406460
## Tree Search and Sorting Semantics
407461

408462
- Search shows matching nodes and their ancestors, so hits remain visible in context

0 commit comments

Comments
 (0)