Skip to content

Commit 5ebcf67

Browse files
MeAkibcrisbeto
authored andcommitted
docs: bind route params to component inputs (#33884)
The routed pages in the docs app read their route params by subscribing to `ActivatedRoute` by hand. `ComponentViewer` combined its own params with its parent's to get both `:id` and `:section`, and `ComponentCategoryList` walked `pathFromRoot` and merged the params of every level to find `:section`. This enables `withComponentInputBinding()` so that the router passes the route params to the routed components as inputs. Nested routes already receive their parent's params, because the router's params inheritance strategy defaults to `always`, so no further router config is needed. Changes: - `GuideViewer`: add an `id` input and derive the guide with `computed`. An `effect` sets the page title, or redirects to `/guides` if no guide matches the id. - `ComponentViewer`: add `id` and `section` inputs and load the doc item from them with `toObservable` and `switchMap`, which still cancels a lookup when the params change before it finishes. The child pages keep reading `componentDocItem`, so they're unchanged. - `ComponentCategoryList`: add a `section` input and turn the summary and items into signals, updating the template to read them. An `effect` sets the page title. - Drop the `ActivatedRoute` injections, `combineLatest` and manual param merging from these components. Tests: - Rewrite the `GuideViewer` spec with `RouterTestingHarness` and a real route config, and cover the redirect for an unknown guide. - Add specs for `ComponentViewer` and `ComponentCategoryList` whose routes match the app's nesting (`:section` > sidenav > page). They cover the params reaching the inputs, the page title, and the component being reused and updated when the params change. (cherry picked from commit d8d6611)
1 parent 8f6d6ed commit 5ebcf67

8 files changed

Lines changed: 281 additions & 99 deletions

File tree

‎docs/src/app/pages/component-category-list/component-category-list.html‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
<div class="docs-component-category-list-summary docs-markdown"
22
id="category-summary"
33
focusOnNavigation>
4-
<div [innerHTML]="_categoryListSummary"></div>
4+
<div [innerHTML]="_categoryListSummary()"></div>
55
</div>
6-
@if (items.length > 0) {
6+
@if (items().length > 0) {
77
<div class="docs-component-category-list">
8-
@for (component of items; track component) {
8+
@for (component of items(); track component) {
99
@if (component.externalRedirect) {
1010
<a class="docs-component-category-list-item" [href]="component.externalRedirect">
1111
<ng-container [ngTemplateOutlet]="card" [ngTemplateOutletContext]="{component}"/>
1212
</a>
1313
} @else {
1414
<a class="docs-component-category-list-item"
15-
[routerLink]="'/' + section + '/' + component.id">
15+
[routerLink]="'/' + section() + '/' + component.id">
1616
<ng-container [ngTemplateOutlet]="card" [ngTemplateOutletContext]="{component}"/>
1717
</a>
1818
}
@@ -22,7 +22,7 @@
2222

2323
<ng-template #card let-component="component">
2424
<div class="docs-component-category-list-card" matRipple>
25-
@if (section === 'components') {
25+
@if (section() === 'components') {
2626
<img class="docs-component-category-list-card-image-wrapper"
2727
[src]="'../../../assets/screenshots/' + component.id + '.scene.png'"
2828
loading="lazy"
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import {Component} from '@angular/core';
2+
import {TestBed} from '@angular/core/testing';
3+
import {By} from '@angular/platform-browser';
4+
import {provideRouter, RouterOutlet, withComponentInputBinding} from '@angular/router';
5+
import {RouterTestingHarness} from '@angular/router/testing';
6+
import {ComponentCategoryList} from './component-category-list';
7+
import {ComponentPageTitle} from '../page-title/page-title';
8+
import {DocumentationItems, SECTIONS} from '../../shared/documentation-items/documentation-items';
9+
10+
/** Stand-in for the sidenav, which sits between the `:section` and `categories` routes. */
11+
@Component({template: '<router-outlet />', imports: [RouterOutlet]})
12+
class Shell {}
13+
14+
/** Waits for pending promises, such as loading the doc items, to settle. */
15+
function flushPromises() {
16+
return new Promise(resolve => setTimeout(resolve));
17+
}
18+
19+
describe('ComponentCategoryList', () => {
20+
let harness: RouterTestingHarness;
21+
22+
/** Navigates to a URL and returns the `ComponentCategoryList` rendered inside the shell. */
23+
async function navigate(url: string): Promise<ComponentCategoryList> {
24+
await harness.navigateByUrl(url);
25+
return harness.fixture.debugElement.query(By.directive(ComponentCategoryList))
26+
?.componentInstance;
27+
}
28+
let docItems: DocumentationItems;
29+
30+
beforeEach(async () => {
31+
TestBed.configureTestingModule({
32+
providers: [
33+
provideRouter(
34+
[
35+
{
36+
path: ':section',
37+
children: [
38+
{
39+
path: '',
40+
component: Shell,
41+
children: [
42+
{path: 'categories', children: [{path: '', component: ComponentCategoryList}]},
43+
],
44+
},
45+
],
46+
},
47+
],
48+
withComponentInputBinding(),
49+
),
50+
],
51+
});
52+
53+
harness = await RouterTestingHarness.create();
54+
docItems = TestBed.inject(DocumentationItems);
55+
56+
// Load the doc data up front so that the component's lookups resolve within one flush.
57+
await docItems.getData();
58+
});
59+
60+
it('should bind the section from the parent route params', async () => {
61+
const component = await navigate('/cdk/categories');
62+
expect(component.section()).toBe('cdk');
63+
});
64+
65+
it('should set the page title to the section name', async () => {
66+
await navigate('/cdk/categories');
67+
expect(TestBed.inject(ComponentPageTitle).title).toBe(SECTIONS['cdk'].name);
68+
});
69+
70+
it('should list the items of the section', async () => {
71+
const component = await navigate('/cdk/categories');
72+
await flushPromises();
73+
74+
expect(component.items()).toEqual(await docItems.getItems('cdk'));
75+
});
76+
77+
it('should update when the section changes', async () => {
78+
const component = await navigate('/cdk/categories');
79+
const reused = await navigate('/components/categories');
80+
await flushPromises();
81+
82+
expect(reused).toBe(component);
83+
expect(component.section()).toBe('components');
84+
expect(TestBed.inject(ComponentPageTitle).title).toBe(SECTIONS['components'].name);
85+
expect(component.items()).toEqual(await docItems.getItems('components'));
86+
});
87+
});

‎docs/src/app/pages/component-category-list/component-category-list.ts‎

Lines changed: 20 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,12 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {ChangeDetectionStrategy, Component, DestroyRef, OnInit, inject} from '@angular/core';
10-
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
11-
import {ActivatedRoute, Params, RouterLink} from '@angular/router';
9+
import {ChangeDetectionStrategy, Component, computed, effect, inject, input} from '@angular/core';
10+
import {toObservable, toSignal} from '@angular/core/rxjs-interop';
11+
import {RouterLink} from '@angular/router';
1212
import {MatRipple} from '@angular/material/core';
1313
import {NgTemplateOutlet} from '@angular/common';
14-
import {combineLatest} from 'rxjs';
15-
import {map} from 'rxjs/operators';
14+
import {switchMap} from 'rxjs/operators';
1615

1716
import {
1817
DocItem,
@@ -30,31 +29,23 @@ import {ComponentPageTitle} from '../page-title/page-title';
3029
imports: [NavigationFocus, RouterLink, MatRipple, NgTemplateOutlet],
3130
changeDetection: ChangeDetectionStrategy.Eager,
3231
})
33-
export class ComponentCategoryList implements OnInit {
32+
export class ComponentCategoryList {
3433
private readonly _docItems = inject(DocumentationItems);
3534
private readonly _componentPageTitle = inject(ComponentPageTitle);
36-
private readonly _route = inject(ActivatedRoute);
37-
private readonly _destroyRef = inject(DestroyRef);
38-
39-
items: DocItem[] = [];
40-
section = '';
41-
_categoryListSummary: string | undefined;
42-
43-
ngOnInit() {
44-
combineLatest(this._route.pathFromRoot.map(route => route.params))
45-
.pipe(
46-
map(allParams =>
47-
allParams.reduce((merged, params) => ({...merged, ...params}), {} as Params),
48-
),
49-
takeUntilDestroyed(this._destroyRef),
50-
)
51-
.subscribe(async params => {
52-
const sectionName = params['section'];
53-
const section = SECTIONS[sectionName];
54-
this._componentPageTitle.title = section.name;
55-
this._categoryListSummary = section.summary;
56-
this.section = sectionName;
57-
this.items = await this._docItems.getItems(sectionName);
58-
});
35+
36+
/** Section whose items are listed (material/cdk). Bound from the `:section` route param. */
37+
readonly section = input.required<string>();
38+
39+
protected readonly _categoryListSummary = computed(() => SECTIONS[this.section()].summary);
40+
41+
readonly items = toSignal(
42+
toObservable(this.section).pipe(switchMap(section => this._docItems.getItems(section))),
43+
{initialValue: [] as DocItem[]},
44+
);
45+
46+
constructor() {
47+
effect(() => {
48+
this._componentPageTitle.title = SECTIONS[this.section()].name;
49+
});
5950
}
6051
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import {Component} from '@angular/core';
2+
import {TestBed} from '@angular/core/testing';
3+
import {By} from '@angular/platform-browser';
4+
import {
5+
NavigationEnd,
6+
provideRouter,
7+
Router,
8+
RouterOutlet,
9+
withComponentInputBinding,
10+
} from '@angular/router';
11+
import {RouterTestingHarness} from '@angular/router/testing';
12+
import {Observable} from 'rxjs';
13+
import {filter, skip, take} from 'rxjs/operators';
14+
import {ComponentViewer} from './component-viewer';
15+
import {ComponentPageTitle} from '../page-title/page-title';
16+
17+
/** Resolves with the first value emitted by an observable. */
18+
function firstValueFrom<T>(source: Observable<T>): Promise<T> {
19+
return source.pipe(take(1)).toPromise() as Promise<T>;
20+
}
21+
22+
/** Stand-in for the sidenav, which sits between the `:section` and `:id` routes. */
23+
@Component({template: '<router-outlet />', imports: [RouterOutlet]})
24+
class Shell {}
25+
26+
@Component({template: ''})
27+
class SectionPage {}
28+
29+
describe('ComponentViewer', () => {
30+
let harness: RouterTestingHarness;
31+
32+
/** Navigates to a URL and returns the `ComponentViewer` rendered inside the shell. */
33+
async function navigate(url: string): Promise<ComponentViewer> {
34+
await harness.navigateByUrl(url);
35+
return harness.fixture.debugElement.query(By.directive(ComponentViewer))?.componentInstance;
36+
}
37+
38+
beforeEach(async () => {
39+
TestBed.configureTestingModule({
40+
providers: [
41+
provideRouter(
42+
[
43+
{
44+
path: ':section',
45+
children: [
46+
{
47+
path: '',
48+
component: Shell,
49+
children: [
50+
{path: '', component: SectionPage},
51+
{path: ':id', component: ComponentViewer},
52+
],
53+
},
54+
],
55+
},
56+
],
57+
withComponentInputBinding(),
58+
),
59+
],
60+
});
61+
62+
harness = await RouterTestingHarness.create();
63+
});
64+
65+
it('should bind the id and section route params', async () => {
66+
const component = await navigate('/cdk/overlay');
67+
expect(component.id()).toBe('overlay');
68+
expect(component.section()).toBe('cdk');
69+
});
70+
71+
it('should load the doc item for the route params', async () => {
72+
const component = await navigate('/components/button');
73+
const doc = await firstValueFrom(component.componentDocItem);
74+
75+
expect(doc.id).toBe('button');
76+
expect(doc.packageName).toBe('material');
77+
expect(TestBed.inject(ComponentPageTitle).title).toBe(doc.name);
78+
});
79+
80+
it('should load the new doc item when the route params change', async () => {
81+
const component = await navigate('/components/button');
82+
await firstValueFrom(component.componentDocItem);
83+
84+
const nextDoc = firstValueFrom(component.componentDocItem.pipe(skip(1)));
85+
const reused = await navigate('/components/checkbox');
86+
87+
expect(reused).toBe(component);
88+
expect((await nextDoc).id).toBe('checkbox');
89+
});
90+
91+
it('should redirect to the section if the doc item does not exist', async () => {
92+
const router = TestBed.inject(Router);
93+
const redirected = firstValueFrom(
94+
router.events.pipe(filter(e => e instanceof NavigationEnd && e.url === '/components')),
95+
);
96+
97+
await navigate('/components/does-not-exist');
98+
await redirected;
99+
100+
expect(router.url).toBe('/components');
101+
});
102+
});

‎docs/src/app/pages/component-viewer/component-viewer.ts‎

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,16 @@ import {
1414
Directive,
1515
OnInit,
1616
ViewEncapsulation,
17+
computed,
18+
input,
1719
viewChild,
1820
viewChildren,
1921
inject,
2022
DestroyRef,
2123
} from '@angular/core';
22-
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
23-
import {ActivatedRoute, Router, RouterLinkActive, RouterLink, RouterOutlet} from '@angular/router';
24-
import {combineLatest, Observable, ReplaySubject} from 'rxjs';
24+
import {takeUntilDestroyed, toObservable} from '@angular/core/rxjs-interop';
25+
import {Router, RouterLinkActive, RouterLink, RouterOutlet} from '@angular/router';
26+
import {Observable, ReplaySubject} from 'rxjs';
2527
import {map, skip, switchMap} from 'rxjs/operators';
2628
import {DocItem, DocumentationItems} from '../../shared/documentation-items/documentation-items';
2729
import {TableOfContents} from '../../shared/table-of-contents/table-of-contents';
@@ -53,25 +55,22 @@ export class ComponentViewer {
5355
readonly docItems = inject(DocumentationItems);
5456
private readonly _destroyRef = inject(DestroyRef);
5557

58+
/** Id of the doc item to display (e.g. button/checkbox). Bound from the `:id` route param. */
59+
readonly id = input.required<string>();
60+
61+
/** Section the doc item belongs to (material/cdk). Bound from the `:section` route param. */
62+
readonly section = input.required<string>();
63+
5664
componentDocItem = new ReplaySubject<DocItem>(1);
5765
sections: Set<string> = new Set(['overview', 'api']);
5866

5967
constructor() {
60-
const route = inject(ActivatedRoute);
6168
const componentPageTitle = this.componentPageTitle;
6269
const docItems = this.docItems;
6370

64-
const routeAndParentParams = [route.params];
65-
if (route.parent) {
66-
routeAndParentParams.push(route.parent.params);
67-
}
68-
// Listen to changes on the current route for the doc id (e.g. button/checkbox) and the
69-
// parent route for the section (material/cdk).
70-
combineLatest(routeAndParentParams)
71+
toObservable(computed(() => ({id: this.id(), section: this.section()})))
7172
.pipe(
72-
switchMap(async params => {
73-
const id = params[0]['id'];
74-
const section = params[1]['section'];
73+
switchMap(async ({id, section}) => {
7574
const doc = await docItems.getItemById(id, section);
7675
return {doc, section};
7776
}),

0 commit comments

Comments
 (0)