From 373c101d02de0cea3fccbbfdf61fcd7ea9c049b0 Mon Sep 17 00:00:00 2001 From: SkyZeroZx <73321943+SkyZeroZx@users.noreply.github.com> Date: Wed, 12 Nov 2025 10:53:31 -0500 Subject: [PATCH] refactor(devtools): improve route data and resolver views Adds an enhanced route data tree view to better visualize both route resolvers and router data. --- .../src/lib/router-tree.spec.ts | 123 ++++++++++++++++++ .../src/lib/router-tree.ts | 32 ++++- .../lib/devtools-tabs/router-tree/BUILD.bazel | 11 ++ .../route-data-tree.component.html | 34 +++++ .../route-data-tree.component.scss | 82 ++++++++++++ .../route-data-tree.component.ts | 96 ++++++++++++++ .../route-details-row.component.html | 25 ++-- .../route-details-row.component.ts | 5 +- .../router-tree/router-tree.component.html | 12 ++ .../projects/protocol/src/lib/messages.ts | 1 + .../app/demo-app/todo/routes/routes.module.ts | 18 ++- 11 files changed, 424 insertions(+), 15 deletions(-) create mode 100644 devtools/projects/ng-devtools/src/lib/devtools-tabs/router-tree/route-data-tree/route-data-tree.component.html create mode 100644 devtools/projects/ng-devtools/src/lib/devtools-tabs/router-tree/route-data-tree/route-data-tree.component.scss create mode 100644 devtools/projects/ng-devtools/src/lib/devtools-tabs/router-tree/route-data-tree/route-data-tree.component.ts diff --git a/devtools/projects/ng-devtools-backend/src/lib/router-tree.spec.ts b/devtools/projects/ng-devtools-backend/src/lib/router-tree.spec.ts index 77cd46d84a6..b2123e8aeb1 100644 --- a/devtools/projects/ng-devtools-backend/src/lib/router-tree.spec.ts +++ b/devtools/projects/ng-devtools-backend/src/lib/router-tree.spec.ts @@ -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()'}, + ]); + }); }); diff --git a/devtools/projects/ng-devtools-backend/src/lib/router-tree.ts b/devtools/projects/ng-devtools-backend/src/lib/router-tree.ts index 7a3c182c1d7..3e2fb73236d 100644 --- a/devtools/projects/ng-devtools-backend/src/lib/router-tree.ts +++ b/devtools/projects/ng-devtools-backend/src/lib/router-tree.ts @@ -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 diff --git a/devtools/projects/ng-devtools/src/lib/devtools-tabs/router-tree/BUILD.bazel b/devtools/projects/ng-devtools/src/lib/devtools-tabs/router-tree/BUILD.bazel index 4200c0b0798..f092fc47102 100644 --- a/devtools/projects/ng-devtools/src/lib/devtools-tabs/router-tree/BUILD.bazel +++ b/devtools/projects/ng-devtools/src/lib/devtools-tabs/router-tree/BUILD.bazel @@ -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", diff --git a/devtools/projects/ng-devtools/src/lib/devtools-tabs/router-tree/route-data-tree/route-data-tree.component.html b/devtools/projects/ng-devtools/src/lib/devtools-tabs/router-tree/route-data-tree/route-data-tree.component.html new file mode 100644 index 00000000000..eb3bd8ff4eb --- /dev/null +++ b/devtools/projects/ng-devtools/src/lib/devtools-tabs/router-tree/route-data-tree/route-data-tree.component.html @@ -0,0 +1,34 @@ + + +
+ {{ node.name }}: + {{ node.preview }} + @if (showViewSourceButton()) { + + } +
+
+ + + +
diff --git a/devtools/projects/ng-devtools/src/lib/devtools-tabs/router-tree/route-data-tree/route-data-tree.component.scss b/devtools/projects/ng-devtools/src/lib/devtools-tabs/router-tree/route-data-tree/route-data-tree.component.scss new file mode 100644 index 00000000000..6532e7c674e --- /dev/null +++ b/devtools/projects/ng-devtools/src/lib/devtools-tabs/router-tree/route-data-tree/route-data-tree.component.scss @@ -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; + } + } +} diff --git a/devtools/projects/ng-devtools/src/lib/devtools-tabs/router-tree/route-data-tree/route-data-tree.component.ts b/devtools/projects/ng-devtools/src/lib/devtools-tabs/router-tree/route-data-tree/route-data-tree.component.ts new file mode 100644 index 00000000000..b6b29b649f0 --- /dev/null +++ b/devtools/projects/ng-devtools/src/lib/devtools-tabs/router-tree/route-data-tree/route-data-tree.component.ts @@ -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(); + + readonly showViewSourceButton = input(false); + + readonly viewSource = output(); + + 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 = {}; + + 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): 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); + } +} diff --git a/devtools/projects/ng-devtools/src/lib/devtools-tabs/router-tree/route-details-row.component.html b/devtools/projects/ng-devtools/src/lib/devtools-tabs/router-tree/route-details-row.component.html index 5b3716f52e8..50d9540f9fd 100644 --- a/devtools/projects/ng-devtools/src/lib/devtools-tabs/router-tree/route-details-row.component.html +++ b/devtools/projects/ng-devtools/src/lib/devtools-tabs/router-tree/route-details-row.component.html @@ -18,18 +18,21 @@ } } @default { -
- - @if (rowValue() && renderValueAsJson()) { - {{ rowValue() | json }} - } @else { - {{ rowValue() || '[empty string] ' }} - } -
+ } @else { +
+ {{ rowValue() || '[empty string] ' }} +
+ } } } diff --git a/devtools/projects/ng-devtools/src/lib/devtools-tabs/router-tree/route-details-row.component.ts b/devtools/projects/ng-devtools/src/lib/devtools-tabs/router-tree/route-details-row.component.ts index 0fa7a2f7581..ab08a370c8e 100644 --- a/devtools/projects/ng-devtools/src/lib/devtools-tabs/router-tree/route-details-row.component.ts +++ b/devtools/projects/ng-devtools/src/lib/devtools-tabs/router-tree/route-details-row.component.ts @@ -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 { diff --git a/devtools/projects/ng-devtools/src/lib/devtools-tabs/router-tree/router-tree.component.html b/devtools/projects/ng-devtools/src/lib/devtools-tabs/router-tree/router-tree.component.html index 49e7ed7a0b7..6033923250c 100644 --- a/devtools/projects/ng-devtools/src/lib/devtools-tabs/router-tree/router-tree.component.html +++ b/devtools/projects/ng-devtools/src/lib/devtools-tabs/router-tree/router-tree.component.html @@ -94,6 +94,18 @@ [data]="data" > } + @if (data.resolvers && data.resolvers.length > 0) { + + } + @if (data.canActivateGuards && data.canActivateGuards.length > 0) { ; data?: any; + resolvers?: any; path: string; component: string; redirectTo?: string; diff --git a/devtools/src/app/demo-app/todo/routes/routes.module.ts b/devtools/src/app/demo-app/todo/routes/routes.module.ts index 4253c95af7b..2d9876bce9a 100644 --- a/devtools/src/app/demo-app/todo/routes/routes.module.ts +++ b/devtools/src/app/demo-app/todo/routes/routes.module.ts @@ -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 = ( + 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],