mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
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:
parent
caaa5ec8e6
commit
373c101d02
11 changed files with 424 additions and 15 deletions
|
|
@ -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()'},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -323,6 +323,7 @@ export interface Route {
|
|||
title?: string;
|
||||
children?: Array<Route>;
|
||||
data?: any;
|
||||
resolvers?: any;
|
||||
path: string;
|
||||
component: string;
|
||||
redirectTo?: string;
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
Loading…
Reference in a new issue