Skip to content

Commit a7d6845

Browse files
hawkgsatscott
authored andcommitted
refactor(devtools): use ng-filter for the router tree search
Use the reusable `ng-filter` for the router tree search.
1 parent a816848 commit a7d6845

10 files changed

Lines changed: 90 additions & 122 deletions

File tree

devtools/projects/ng-devtools/src/lib/devtools-tabs/router-tree/BUILD.bazel

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,9 @@ ng_project(
3636
"//devtools/projects/ng-devtools/src/lib/application-services:frame_manager",
3737
"//devtools/projects/ng-devtools/src/lib/devtools-tabs/router-tree/router-details-row",
3838
"//devtools/projects/ng-devtools/src/lib/shared/button",
39+
"//devtools/projects/ng-devtools/src/lib/shared/filter",
3940
"//devtools/projects/ng-devtools/src/lib/shared/split",
4041
"//devtools/projects/ng-devtools/src/lib/shared/tree-visualizer",
41-
"//devtools/projects/ng-devtools/src/lib/shared/utils",
4242
"//devtools/projects/protocol",
4343
],
4444
)

devtools/projects/ng-devtools/src/lib/devtools-tabs/router-tree/router-tree-fns.spec.ts

Lines changed: 0 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88

99
import {Route} from '../../../../../protocol';
1010
import {
11-
findNodesByLabel,
1211
getRouteLabel,
1312
mapRoute,
1413
RouterTreeNode,
@@ -98,40 +97,4 @@ describe('router-tree-fns', () => {
9897
} as RouterTreeNode);
9998
});
10099
});
101-
102-
describe('findNodesByLabel', () => {
103-
const home = {
104-
label: '/home',
105-
};
106-
const contacts = {
107-
label: '/contacts',
108-
};
109-
const about = {
110-
label: '/about',
111-
children: [contacts],
112-
};
113-
const aboutProduct = {
114-
label: '/about-product',
115-
};
116-
const root = {
117-
label: '/',
118-
children: [home, about, aboutProduct],
119-
} as RouterTreeNode;
120-
121-
it('should return no results if an empty search string is provided', () => {
122-
const result = findNodesByLabel(root, '');
123-
expect(result).toEqual(new Set([]));
124-
});
125-
126-
it('should find nodes by label', () => {
127-
const result1 = findNodesByLabel(root, 'cont');
128-
expect(result1).toEqual(new Set([contacts]));
129-
130-
const result2 = findNodesByLabel(root, 'about');
131-
expect(result2).toEqual(new Set([about, aboutProduct]));
132-
133-
const result3 = findNodesByLabel(root, 'products');
134-
expect(result3).toEqual(new Set([]));
135-
});
136-
});
137100
});

devtools/projects/ng-devtools/src/lib/devtools-tabs/router-tree/router-tree-fns.ts

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -62,26 +62,3 @@ export function transformRoutesIntoVisTree(root: Route, showFullPath: boolean):
6262

6363
return rootNode!;
6464
}
65-
66-
export function findNodesByLabel(root: RouterTreeNode, searchString: string): Set<RouterTreeNode> {
67-
let matches: Set<RouterTreeNode> = new Set();
68-
69-
if (!searchString) {
70-
return matches;
71-
}
72-
73-
const traverse = (node: RouterTreeNode) => {
74-
if (node.label.toLowerCase().includes(searchString)) {
75-
matches.add(node);
76-
}
77-
78-
if (node.children) {
79-
for (const child of node.children) {
80-
traverse(child);
81-
}
82-
}
83-
};
84-
traverse(root);
85-
86-
return matches;
87-
}

devtools/projects/ng-devtools/src/lib/devtools-tabs/router-tree/router-tree.component.html

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
@if (routerDebugApiSupport()) {
22
<div class="filter">
3-
<input
4-
#searchInput
5-
(input)="searchRoutes($event.target.value)"
3+
<ng-filter
4+
#filter
5+
(filter)="handleFilter($event)"
6+
(nextMatched)="navigateMatchedRoute('next')"
7+
(prevMatched)="navigateMatchedRoute('prev')"
8+
[matchesCount]="searchMatches().length"
9+
[currentMatch]="currentSearchMatchIdx() + 1"
610
placeholder="Search routes"
7-
class="ng-input filter-input"
11+
[debounce]="250"
812
/>
913
<div class="show-full-path">
1014
<input id="show-full-path" type="checkbox" (change)="togglePathSettings()" />

devtools/projects/ng-devtools/src/lib/devtools-tabs/router-tree/router-tree.component.scss

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@
88
.filter {
99
border-bottom: 1px solid var(--color-separator);
1010
display: flex;
11+
justify-content: space-between;
1112
gap: 1.25rem;
1213
padding: 0.5rem;
1314

14-
.filter-input {
15-
width: 12rem;
15+
ng-filter {
16+
--ng-filter-width: 8rem;
17+
--ng-filter-height: 24px;
1618
}
1719

1820
.show-full-path {

devtools/projects/ng-devtools/src/lib/devtools-tabs/router-tree/router-tree.component.ts

Lines changed: 54 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {
10-
Component,
11-
computed,
12-
DestroyRef,
13-
ElementRef,
14-
inject,
15-
input,
16-
linkedSignal,
17-
signal,
18-
viewChild,
19-
} from '@angular/core';
9+
import {Component, computed, inject, input, linkedSignal, signal, viewChild} from '@angular/core';
2010
import {TreeVisualizerComponent} from '../../shared/tree-visualizer/tree-visualizer.component';
2111
import {MatIconModule} from '@angular/material/icon';
2212
import {MatSnackBar, MatSnackBarModule} from '@angular/material/snack-bar';
@@ -29,15 +19,14 @@ import {
2919
RouterTreeD3Node,
3020
transformRoutesIntoVisTree,
3121
RouterTreeNode,
32-
findNodesByLabel,
3322
RouterTreeVisualizer,
3423
} from './router-tree-fns';
3524
import {ButtonComponent} from '../../shared/button/button.component';
3625
import {SplitComponent} from '../../shared/split/split.component';
3726
import {SplitAreaDirective} from '../../shared/split/splitArea.directive';
38-
import {Debouncer} from '../../shared/utils/debouncer';
27+
import {FilterComponent, FilterFn} from '../../shared/filter/filter.component';
3928

40-
const SEARCH_DEBOUNCE = 250;
29+
const NODE_SNAP_SCALE = 0.6;
4130
const RUN_GUARDS_AND_RESOLVERS_OPTIONS: RunGuardsAndResolvers[] = [
4231
'pathParamsChange',
4332
'pathParamsOrQueryParamsChange',
@@ -58,10 +47,11 @@ const RUN_GUARDS_AND_RESOLVERS_OPTIONS: RunGuardsAndResolvers[] = [
5847
MatSnackBarModule,
5948
RouteDetailsRowComponent,
6049
ButtonComponent,
50+
FilterComponent,
6151
],
6252
})
6353
export class RouterTreeComponent {
64-
private readonly searchInput = viewChild.required<ElementRef>('searchInput');
54+
private readonly filter = viewChild.required<FilterComponent>('filter');
6555
private readonly routerTree = viewChild.required<RouterTreeVisualizer>('routerTree');
6656

6757
private readonly messageBus = inject(MessageBus) as MessageBus<Events>;
@@ -80,6 +70,9 @@ export class RouterTreeComponent {
8070
),
8171
);
8272

73+
protected readonly currentSearchMatchIdx = signal<number>(-1);
74+
protected readonly searchMatches = signal<RouterTreeNode[]>([]);
75+
8376
routes = input.required<Route[]>();
8477
routerDebugApiSupport = input<boolean>(false);
8578

@@ -92,36 +85,13 @@ export class RouterTreeComponent {
9285
return null;
9386
});
9487

95-
private searchMatches: Set<RouterTreeNode> = new Set();
96-
97-
private readonly searchDebouncer = new Debouncer();
98-
99-
protected readonly searchRoutes = this.searchDebouncer.debounce((inputValue: string) => {
100-
const d3RootNode = this.d3RootNode();
101-
if (!d3RootNode) {
102-
return;
103-
}
104-
this.searchMatches = findNodesByLabel(d3RootNode, inputValue.toLowerCase());
105-
// Since `searchMatches` is used in the D3 node modifier, reset the root to trigger a re-render.
106-
// Consider: Ideally, we could perform the search visual changes via direct DOM manipulations
107-
// that won't require re-rendering the whole tree.
108-
this.d3RootNode.set({...d3RootNode});
109-
}, SEARCH_DEBOUNCE);
110-
11188
protected readonly routerTreeConfig: Partial<TreeVisualizerConfig<RouterTreeNode>> = {
11289
nodeSeparation: () => 1,
11390
d3NodeModifier: (n) => this.d3NodeModifier(n),
11491
};
11592

116-
constructor() {
117-
inject(DestroyRef).onDestroy(() => {
118-
this.searchDebouncer.cancel();
119-
});
120-
}
121-
12293
togglePathSettings(): void {
123-
this.searchInput().nativeElement.value = '';
124-
this.searchMatches = new Set();
94+
this.filter().clearFilter();
12595
this.showFullPath.update((v) => !v);
12696
}
12797

@@ -181,7 +151,7 @@ export class RouterTreeComponent {
181151

182152
onRouterTreeRender({initial}: {initial: boolean}) {
183153
if (initial) {
184-
this.routerTree().snapToRoot(0.6);
154+
this.routerTree().snapToRoot(NODE_SNAP_SCALE);
185155
}
186156
}
187157

@@ -190,6 +160,50 @@ export class RouterTreeComponent {
190160
this.routerTree().snapToNode(node.data, 0.7);
191161
}
192162

163+
navigateMatchedRoute(dir: 'prev' | 'next') {
164+
const dirIdx = dir === 'next' ? 1 : -1;
165+
const matches = Array.from(this.searchMatches());
166+
167+
const newMatchedIdx = (this.currentSearchMatchIdx() + dirIdx + matches.length) % matches.length;
168+
const newMatchedNode = matches[newMatchedIdx];
169+
170+
this.routerTree().snapToNode(newMatchedNode, NODE_SNAP_SCALE);
171+
this.routerTree().highlightNode(newMatchedNode);
172+
this.currentSearchMatchIdx.set(newMatchedIdx);
173+
}
174+
175+
handleFilter(filterFn: FilterFn): void {
176+
this.currentSearchMatchIdx.set(-1);
177+
this.searchMatches.set([]);
178+
const d3RootNode = this.d3RootNode();
179+
180+
if (!d3RootNode) {
181+
return;
182+
}
183+
const matches: RouterTreeNode[] = [];
184+
const traverse = (node: RouterTreeNode) => {
185+
if (filterFn(node.label.toLowerCase()).length) {
186+
matches.push(node);
187+
}
188+
189+
if (node.children) {
190+
for (const child of node.children) {
191+
traverse(child);
192+
}
193+
}
194+
};
195+
traverse(d3RootNode);
196+
197+
this.searchMatches.update((curr) => curr.concat(matches));
198+
199+
// Select the first match, if there are any.
200+
if (this.searchMatches().length) {
201+
this.navigateMatchedRoute('next');
202+
} else {
203+
this.routerTree().highlightNode(null);
204+
}
205+
}
206+
193207
private d3NodeModifier(d3Node: SvgD3Node<RouterTreeNode>) {
194208
d3Node.attr('class', (node: RouterTreeD3Node) => {
195209
// Drop all class labels and recompute them.
@@ -210,12 +224,6 @@ export class RouterTreeComponent {
210224
nodeClasses.push('node-element');
211225
}
212226

213-
if (this.searchMatches.has(node.data)) {
214-
nodeClasses.push('node-search');
215-
} else if (this.searchMatches.size) {
216-
nodeClasses.push('node-faded');
217-
}
218-
219227
return nodeClasses.join(' ');
220228
});
221229
}

devtools/projects/ng-devtools/src/lib/shared/tree-visualizer/graph-renderer.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export abstract class GraphRenderer<T, U> {
1111
abstract getInternalNodeById(id: string): U | null;
1212
abstract snapToNode(node: T): void;
1313
abstract snapToRoot(): void;
14+
abstract highlightNode(node: T | null): void;
1415
abstract zoomScale(scale: number): void;
1516
abstract root: U | null;
1617

devtools/projects/ng-devtools/src/lib/shared/tree-visualizer/tree-visualizer.component.scss

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@
6868
border-width: 2px;
6969
font-weight: 300;
7070

71+
&.node-highlighted {
72+
outline: 4px solid var(--blue-03);
73+
}
74+
75+
/* Instance-specific styles below. */
76+
7177
&.node-environment {
7278
border: 1px solid var(--red-05);
7379
background: var(--red-06);
@@ -113,22 +119,15 @@
113119
}
114120
}
115121

116-
&.node-search {
117-
outline: 4px solid
118-
color-mix(in srgb, var(--quaternary-contrast) 80%, var(--full-contrast) 20%);
119-
}
120-
121-
&.node-faded {
122-
opacity: 0.3;
123-
}
124-
122+
/* TODO(hawkgs): Rename to something more context-specific. */
125123
&.highlighted,
126124
&.highlighted:hover {
127125
background: var(--blue-02);
128126
border-color: white;
129127
color: white;
130128
}
131129

130+
/* TODO(hawkgs): Rename to something more context-specific. */
132131
&.selected,
133132
&.selected:hover {
134133
color: var(--blue-02);

devtools/projects/ng-devtools/src/lib/shared/tree-visualizer/tree-visualizer.component.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,10 @@ export class TreeVisualizerComponent<T extends TreeNode = TreeNode> {
102102
this.visualizer()?.snapToNode(node, scale);
103103
}
104104

105+
highlightNode(node: T | null) {
106+
this.visualizer()?.highlightNode(node);
107+
}
108+
105109
getNodeById(id: string) {
106110
return this.visualizer()?.getInternalNodeById(id);
107111
}

devtools/projects/ng-devtools/src/lib/shared/tree-visualizer/tree-visualizer.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ function wrapEvent<E, V>(fn: (e: E, node: V) => void): (e: E, node: V) => void {
6464
export class TreeVisualizer<T extends TreeNode = TreeNode> extends GraphRenderer<T, TreeD3Node<T>> {
6565
private zoomController: d3.ZoomBehavior<HTMLElement, unknown> | null = null;
6666
private snappedNode: {node: TreeD3Node<T>; scale: number} | null = null;
67+
private highlightedNode: T | null = null;
6768
private snappedNodeListenersDisposeFn?: () => void;
6869
private readonly config: TreeVisualizerConfig<T>;
6970
private readonly defaultConfig: TreeVisualizerConfig<T> = {
@@ -117,6 +118,14 @@ export class TreeVisualizer<T extends TreeNode = TreeNode> extends GraphRenderer
117118
}
118119
}
119120

121+
/** Adds an outline to the provided node. Using `null`, clear the highlighted node. */
122+
override highlightNode(node: T | null) {
123+
this.highlightedNode = node;
124+
d3.select(this.containerElement)
125+
.selectAll<HTMLElement, TreeD3Node<T>>('.node-wrapper .node')
126+
.classed('node-highlighted', (n) => (node ? n.data === node : false));
127+
}
128+
120129
override getInternalNodeById(id: string): TreeD3Node<T> | null {
121130
const selection = d3
122131
.select<HTMLElement, TreeD3Node<T>>(this.containerElement)
@@ -301,6 +310,7 @@ export class TreeVisualizer<T extends TreeNode = TreeNode> extends GraphRenderer
301310
.attr('y', -halfLabelHeight)
302311
.append('xhtml:div')
303312
.attr('class', 'node')
313+
.classed('highlighted', (n) => n.data === this.highlightedNode)
304314
.style('position', 'relative')
305315
.text((node: TreeD3Node<T>) => {
306316
const label = node.data.label;

0 commit comments

Comments
 (0)