refactor(devtools): improve route data and resolver views

Adds an enhanced route data tree view to better visualize both route resolvers and router data.
This commit is contained in:
SkyZeroZx 2025-11-12 10:53:31 -05:00 committed by Jessica Janiuk
parent caaa5ec8e6
commit 373c101d02
11 changed files with 424 additions and 15 deletions

View file

@ -121,6 +121,7 @@ describe('parseRoutes', () => {
'canMatchGuards': [],
'canDeactivateGuards': [],
'providers': [],
'resolvers': [],
'path': '/(outlet:component-one)',
'pathMatch': undefined,
'data': [],
@ -135,6 +136,7 @@ describe('parseRoutes', () => {
'canMatchGuards': [],
'canDeactivateGuards': [],
'providers': [],
'resolvers': [],
'path': '/component-two',
'pathMatch': undefined,
'title': 'Component Two',
@ -150,6 +152,7 @@ describe('parseRoutes', () => {
'canMatchGuards': [],
'canDeactivateGuards': [],
'providers': [],
'resolvers': [],
'path': '/component-two/component-two-one',
'pathMatch': undefined,
'title': '[Function]',
@ -165,6 +168,7 @@ describe('parseRoutes', () => {
'canMatchGuards': [],
'canDeactivateGuards': [],
'providers': [],
'resolvers': [],
'path': '/component-two/component-two-two',
'pathMatch': undefined,
'title': 'titleResolver()',
@ -182,6 +186,7 @@ describe('parseRoutes', () => {
'canMatchGuards': [],
'canDeactivateGuards': [],
'providers': [],
'resolvers': [],
'path': '/lazy',
'pathMatch': undefined,
'data': [],
@ -196,6 +201,7 @@ describe('parseRoutes', () => {
'canMatchGuards': [],
'canDeactivateGuards': [],
'providers': [],
'resolvers': [],
'path': '/redirect',
'pathMatch': undefined,
'data': [],
@ -211,6 +217,7 @@ describe('parseRoutes', () => {
'canMatchGuards': [],
'canDeactivateGuards': [],
'providers': [],
'resolvers': [],
'path': '/redirect-fn',
'pathMatch': undefined,
'data': [],
@ -226,6 +233,7 @@ describe('parseRoutes', () => {
'canMatchGuards': [],
'canDeactivateGuards': [],
'providers': [],
'resolvers': [],
'path': '/redirect-named-fn',
'pathMatch': undefined,
'data': [],
@ -341,4 +349,119 @@ describe('parseRoutes', () => {
expect(parsedRoutes.children![0].canMatchGuards).toEqual(['canMatchGuard()']);
expect(parsedRoutes.children![0].canDeactivateGuards).toEqual(['CanDeactivateGuard']);
});
it('should handle resolvers with named functions', () => {
function userResolver() {
return {id: 1, name: 'User'};
}
const nestedRouter = {
config: [
{
path: 'user',
component: 'UserComponent',
resolve: {
user: userResolver,
},
},
],
};
const parsedRoutes = parseRoutes(nestedRouter as any);
expect(parsedRoutes.children![0].resolvers).toEqual([{key: 'user', value: 'userResolver()'}]);
});
it('should handle resolvers with arrow functions', () => {
const dataResolver = () => ({data: 'value'});
const nestedRouter = {
config: [
{
path: 'data',
component: 'DataComponent',
resolve: {
data: dataResolver,
},
},
],
};
const parsedRoutes = parseRoutes(nestedRouter as any);
expect(parsedRoutes.children![0].resolvers).toEqual([{key: 'data', value: 'dataResolver()'}]);
});
it('should handle multiple resolvers on a single route', () => {
function userResolver() {
return {id: 1};
}
const settingsResolver = () => ({theme: 'dark'});
class PermissionsResolver {
resolve() {
return ['read', 'write'];
}
}
const nestedRouter = {
config: [
{
path: 'dashboard',
component: 'DashboardComponent',
resolve: {
user: userResolver,
settings: settingsResolver,
permissions: PermissionsResolver,
},
},
],
};
const parsedRoutes = parseRoutes(nestedRouter as any);
expect(parsedRoutes.children![0].resolvers).toEqual([
{key: 'user', value: 'userResolver()'},
{key: 'settings', value: 'settingsResolver()'},
{key: 'permissions', value: 'PermissionsResolver'},
]);
});
it('should handle nested routes with resolvers', () => {
function parentResolver() {
return {parent: 'data'};
}
function childResolver() {
return {child: 'data'};
}
const nestedRouter = {
config: [
{
path: 'parent',
component: 'ParentComponent',
resolve: {
parentData: parentResolver,
},
children: [
{
path: 'child',
component: 'ChildComponent',
resolve: {
childData: childResolver,
},
},
],
},
],
};
const parsedRoutes = parseRoutes(nestedRouter as any);
expect(parsedRoutes.children![0].resolvers).toEqual([
{key: 'parentData', value: 'parentResolver()'},
]);
expect(parsedRoutes.children![0].children![0].resolvers).toEqual([
{key: 'childData', value: 'childResolver()'},
]);
});
});

View file

@ -9,7 +9,13 @@
import {Route} from '../../../protocol';
import type {Route as AngularRoute, ActivatedRoute} from '@angular/router';
export type RoutePropertyType = RouteGuard | 'providers' | 'component' | 'redirectTo' | 'title';
export type RoutePropertyType =
| RouteGuard
| 'providers'
| 'component'
| 'redirectTo'
| 'title'
| 'resolvers';
export type RouteGuard = 'canActivate' | 'canActivateChild' | 'canDeactivate' | 'canMatch';
@ -129,6 +135,7 @@ function assignChildrenToParent(
providers: getProviderName(child),
path: routePath,
data: [],
resolvers: [],
isAux,
isLazy,
isActive,
@ -150,6 +157,17 @@ function assignChildrenToParent(
);
}
if (child.resolve) {
for (const el in child.resolve) {
if (child.resolve.hasOwnProperty(el)) {
routeConfig?.resolvers?.push({
key: el,
value: getClassOrFunctionName(child.resolve[el]),
});
}
}
}
if (child.data) {
for (const el in child.data) {
if (child.data.hasOwnProperty(el)) {
@ -232,6 +250,18 @@ export function getElementRefByName(
}
}
if (type === 'resolvers' && element.resolve) {
for (const key in element.resolve) {
if (element.resolve.hasOwnProperty(key)) {
const functionName = getClassOrFunctionName(element.resolve[key]);
//TODO: improve this, not every ResolverFn has a name property
if (functionName === name) {
return element.resolve[key];
}
}
}
}
if (type === 'redirectTo' && element.redirectTo instanceof Function) {
const functionName = getClassOrFunctionName(element.redirectTo);
//TODO: improve this, not every redirectToFn has a name property

View file

@ -15,9 +15,18 @@ sass_binary(
src = "route-details-row.component.scss",
)
sass_binary(
name = "route_data_tree_styles",
src = "route-data-tree/route-data-tree.component.scss",
deps = [
"//devtools/projects/ng-devtools/src/styles:typography",
],
)
ng_project(
name = "router-tree",
srcs = [
"route-data-tree/route-data-tree.component.ts",
"route-details-row.component.ts",
"router-tree.component.ts",
"router-tree-fns.ts",
@ -27,6 +36,8 @@ ng_project(
":router_tree_styles",
":route-details-row.component.html",
":router_details_row_styles",
":route-data-tree/route-data-tree.component.html",
":route_data_tree_styles",
],
deps = [
"//:node_modules/@angular/common",

View file

@ -0,0 +1,34 @@
<mat-tree #tree [dataSource]="treeData()" [childrenAccessor]="childrenAccessor">
<mat-tree-node matTreeNodePaddingIndent="14" *matTreeNodeDef="let node" matTreeNodePadding>
<div class="data-row non-expandable-row">
<span class="name">{{ node.name }}</span
>:
<span class="value">{{ node.preview }}</span>
@if (showViewSourceButton()) {
<button
type="button"
(click)="handleViewSource(node)"
[matTooltip]="'View source'"
class="data-action-btn"
>
<mat-icon class="view-source">code</mat-icon>
</button>
}
</div>
</mat-tree-node>
<mat-tree-node
matTreeNodePaddingIndent="14"
*matTreeNodeDef="let node; when: hasChild"
matTreeNodePadding
[isExpandable]="node.isExpandable"
>
<button matTreeNodeToggle class="data-row expandable-row">
<mat-icon class="expand-icon">
{{ tree.isExpanded(node) ? 'expand_more' : 'chevron_right' }}
</mat-icon>
<span class="name">{{ node.name }}</span
>:
<span class="value">{{ node.preview }}</span>
</button>
</mat-tree-node>
</mat-tree>

View file

@ -0,0 +1,82 @@
@use '../../../../styles/typography';
:host {
width: 100%;
display: block;
overflow: auto;
.data-row {
$margin-left: 0.75rem;
$second-row-text-indent: 0.5rem;
@extend %monospaced;
display: inline;
text-indent: -$second-row-text-indent;
margin-left: $margin-left + $second-row-text-indent;
&.expandable-row {
position: relative;
border: none;
background: none;
padding: 0;
text-align: left;
.expand-icon {
position: absolute;
font-size: 12px;
width: 12px;
height: 12px;
top: 0.175rem;
left: -0.875rem - $second-row-text-indent;
text-indent: 0;
cursor: pointer;
}
}
}
mat-tree-node {
min-height: 1.375rem !important;
cursor: default;
}
.name {
color: var(--color-tree-node-element-name);
}
.value:not(:last-child) {
margin-right: 0.25rem;
}
.data-action-btn {
position: relative;
border-radius: 50%;
flex: 0 0 16px;
width: 16px;
height: 16px;
border: none;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--quaternary-contrast);
background-color: transparent;
vertical-align: middle;
padding: 0;
top: -1px;
cursor: pointer;
margin-left: 0.25rem;
&:hover {
$color: color-mix(in srgb, var(--dynamic-blue-02) 80%, var(--full-contrast) 20%);
background: $color;
outline: 2px solid $color;
color: var(--septenary-contrast);
}
mat-icon {
width: 16px;
height: 16px;
font-size: 16px;
top: 0;
}
}
}

View file

@ -0,0 +1,96 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {ChangeDetectionStrategy, Component, computed, input, output} from '@angular/core';
import {MatIconModule} from '@angular/material/icon';
import {MatTooltipModule} from '@angular/material/tooltip';
import {MatTreeModule} from '@angular/material/tree';
interface TreeNode {
name: string;
value: any;
preview: string;
isExpandable: boolean;
children: TreeNode[];
}
@Component({
selector: 'ng-route-data-tree',
templateUrl: './route-data-tree.component.html',
styleUrls: ['./route-data-tree.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MatTreeModule, MatIconModule, MatTooltipModule],
})
export class RouteDataTreeComponent {
readonly data = input.required<any>();
readonly showViewSourceButton = input(false);
readonly viewSource = output<string>();
protected readonly treeData = computed(() => this.buildTree(this.data()));
protected readonly childrenAccessor = (node: TreeNode): TreeNode[] => node.children;
private buildTree(data: any): TreeNode[] {
const isArray = Array.isArray(data);
const hasKeyValue = isArray && data?.[0]?.key !== undefined;
if (isArray && hasKeyValue) {
const obj: Record<string, any> = {};
for (const item of data) {
obj[item.key] = item.value;
}
return this.buildObjectNodes(obj);
}
return isArray ? this.buildArrayNodes(data) : this.buildObjectNodes(data);
}
private isObject(value: any): boolean {
return value !== null && typeof value === 'object';
}
private buildArrayNodes(items: any[]): TreeNode[] {
return items.map((item, index) => this.createNode(`[${index}]`, item));
}
private buildObjectNodes(obj: Record<string, any>): TreeNode[] {
return Object.keys(obj).map((key) => this.createNode(key, obj[key]));
}
private createNode(name: string, value: any): TreeNode {
const isExpandable = this.isObject(value);
return {
name,
value,
preview: this.getValuePreview(value),
isExpandable,
children: isExpandable ? this.buildTree(value) : [],
};
}
protected hasChild = (_: number, node: TreeNode): boolean => node.isExpandable;
protected handleViewSource(node: TreeNode): void {
this.viewSource.emit(node.value);
}
private getValuePreview(value: any): string {
const type = typeof value;
if (type === 'string') return value;
if (type === 'number' || type === 'boolean') return String(value);
if (Array.isArray(value)) return `[${value.length}]`;
if (type === 'object') return `{...}`;
return String(value);
}
}

View file

@ -18,18 +18,21 @@
}
}
@default {
<div class="value-container">
<span>
@if (rowValue() && renderValueAsJson()) {
{{ rowValue() | json }}
} @else {
{{ rowValue() || '[empty string] ' }}
}</span
><ng-container
[ngTemplateOutlet]="actionBtn"
[ngTemplateOutletContext]="{ value: rowValue() }"
@if (rowValue() && renderValueAsJson()) {
<ng-route-data-tree
(viewSource)="actionBtnClick.emit($event)"
[showViewSourceButton]="actionBtnType() === 'view-source'"
[data]="rowValue()"
/>
</div>
} @else {
<div class="value-container">
<span> {{ rowValue() || '[empty string] ' }} </span
><ng-container
[ngTemplateOutlet]="actionBtn"
[ngTemplateOutletContext]="{ value: rowValue() }"
/>
</div>
}
}
}
</td>

View file

@ -7,11 +7,12 @@
*/
import {Component, computed, input, output, ChangeDetectionStrategy} from '@angular/core';
import {JsonPipe, NgTemplateOutlet} from '@angular/common';
import {NgTemplateOutlet} from '@angular/common';
import {MatIcon} from '@angular/material/icon';
import {ButtonComponent} from '../../shared/button/button.component';
import {RouterTreeNode} from './router-tree-fns';
import {MatTooltip} from '@angular/material/tooltip';
import {RouteDataTreeComponent} from './route-data-tree/route-data-tree.component';
export type RowType = 'text' | 'flag' | 'list';
export type ActionBtnType = 'none' | 'view-source' | 'navigate';
@ -20,7 +21,7 @@ export type ActionBtnType = 'none' | 'view-source' | 'navigate';
selector: '[ng-route-details-row]',
templateUrl: './route-details-row.component.html',
styleUrls: ['./route-details-row.component.scss'],
imports: [NgTemplateOutlet, ButtonComponent, JsonPipe, MatIcon, MatTooltip],
imports: [NgTemplateOutlet, ButtonComponent, MatIcon, MatTooltip, RouteDataTreeComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RouteDetailsRowComponent {

View file

@ -94,6 +94,18 @@
[data]="data"
></tr>
}
@if (data.resolvers && data.resolvers.length > 0) {
<tr
ng-route-details-row
label="Resolvers"
[renderValueAsJson]="true"
dataKey="resolvers"
[data]="data"
actionBtnType="view-source"
(actionBtnClick)="viewSourceFromRouter($event, 'resolvers')"
></tr>
}
@if (data.canActivateGuards && data.canActivateGuards.length > 0) {
<tr
ng-route-details-row

View file

@ -323,6 +323,7 @@ export interface Route {
title?: string;
children?: Array<Route>;
data?: any;
resolvers?: any;
path: string;
component: string;
redirectTo?: string;

View file

@ -9,10 +9,12 @@
import {CommonModule} from '@angular/common';
import {NgModule} from '@angular/core';
import {
ActivatedRouteSnapshot,
CanActivateFn,
RouterModule,
RouterStateSnapshot,
ActivatedRouteSnapshot,
Resolve,
ResolveFn,
} from '@angular/router';
import {
@ -26,6 +28,13 @@ import {
Service4,
} from './routes.component';
export const resolverFn: ResolveFn<any> = (
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot,
) => {
return {data: 'Resolved Data from resolverFn'};
};
export const activateGuard: CanActivateFn = (
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot,
@ -66,6 +75,13 @@ export const activateGuard: CanActivateFn = (
message: 'Hello from route!!',
},
},
{
path: 'route-resolver',
component: RoutesHomeComponent,
resolve: {
resolvedData: resolverFn,
},
},
]),
],
declarations: [RoutesHomeComponent],