refactor(docs-infra): use reactive APIs in adev components (#58357)

This is a relativelly small refactoring to a couple of
adev components. The goal here is to use new reactive
APIs in the idiomatic way.

PR Close #58357
This commit is contained in:
Pawel Kozlowski 2024-10-25 13:38:59 +02:00 committed by Alex Rickabaugh
parent 3d918de05f
commit 3e50842593
9 changed files with 147 additions and 191 deletions

View file

@ -1,3 +1 @@
@if (apiItemType()) {
{{ apiItemType()! | adevApiLabel: labelMode() }}
}
{{ type() | adevApiLabel: mode() }}

View file

@ -25,7 +25,7 @@ describe('ApiItemLabel', () => {
});
it('should by default display short label for Class', () => {
component.type = ApiItemType.CLASS;
fixture.componentRef.setInput('type', ApiItemType.CLASS);
fixture.detectChanges();
const label = fixture.nativeElement.innerText;
@ -34,8 +34,8 @@ describe('ApiItemLabel', () => {
});
it('should display full label for Class when labelMode equals full', () => {
component.type = ApiItemType.CLASS;
component.mode = 'full';
fixture.componentRef.setInput('type', ApiItemType.CLASS);
fixture.componentRef.setInput('mode', 'full');
fixture.detectChanges();
const label = fixture.nativeElement.innerText;
@ -44,8 +44,8 @@ describe('ApiItemLabel', () => {
});
it('should display short label for Class when labelMode equals short', () => {
component.type = ApiItemType.CLASS;
component.mode = 'short';
fixture.componentRef.setInput('type', ApiItemType.CLASS);
fixture.componentRef.setInput('mode', 'short');
fixture.detectChanges();
const label = fixture.nativeElement.innerText;

View file

@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {ChangeDetectionStrategy, Component, Input, signal} from '@angular/core';
import {ChangeDetectionStrategy, Component, input} from '@angular/core';
import {ApiItemType} from '../interfaces/api-item-type';
import {ApiLabel} from '../pipes/api-label.pipe';
@ -16,19 +16,12 @@ import {ApiLabel} from '../pipes/api-label.pipe';
templateUrl: './api-item-label.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[attr.data-type]': 'apiItemType()',
'[attr.data-mode]': 'labelMode()',
'[attr.data-type]': 'type()',
'[attr.data-mode]': 'mode()',
},
imports: [ApiLabel],
})
export default class ApiItemLabel {
@Input({required: true}) set type(value: ApiItemType) {
this.apiItemType.set(value);
}
@Input({required: true}) set mode(value: 'short' | 'full') {
this.labelMode.set(value);
}
protected apiItemType = signal<ApiItemType | undefined>(undefined);
protected labelMode = signal<'short' | 'full'>('short');
readonly type = input.required<ApiItemType>();
readonly mode = input.required<'short' | 'full'>();
}

View file

@ -1,22 +1,22 @@
<header class="adev-api-items-section-header">
<h3 [attr.id]="group.id">
@if (group.isFeatured) {
<h3 [id]="group().id">
@if (group().isFeatured) {
<docs-icon aria-hidden>star</docs-icon>
}
<!-- we use innerHtml because the title can be an html string-->
<a
routerLink="/api"
[fragment]="group.id"
[fragment]="group().id"
queryParamsHandling="preserve"
class="adev-api-anchor"
tabindex="-1"
[innerHtml]="group.title"
[innerHtml]="group().title"
></a>
</h3>
</header>
<ul class="adev-api-items-section-grid">
@for (apiItem of group.items; track apiItem.url) {
@for (apiItem of group().items; track apiItem.url) {
<li [class.adev-api-items-section-item-deprecated]="apiItem.isDeprecated">
<a
[routerLink]="'/' + apiItem.url"
@ -37,7 +37,7 @@
@if (apiItem.isFeatured) {
<docs-icon
class="adev-api-items-section-item-featured"
[class.adev-api-item-in-featured-section]="group.isFeatured"
[class.adev-api-item-in-featured-section]="group().isFeatured"
[class.adev-api-items-section-item-featured-active]="apiItem.isFeatured"
>
star

View file

@ -66,7 +66,7 @@ describe('ApiItemsSection', () => {
});
it('should render star icon for featured group', () => {
component.group = fakeFeaturedGroup;
fixture.componentRef.setInput('group', fakeFeaturedGroup);
fixture.detectChanges();
const starIcon = fixture.debugElement.query(By.css('.adev-api-items-section-header docs-icon'));
@ -75,7 +75,7 @@ describe('ApiItemsSection', () => {
});
it('should not render star icon for standard group', () => {
component.group = fakeGroup;
fixture.componentRef.setInput('group', fakeGroup);
fixture.detectChanges();
const starIcon = fixture.debugElement.query(By.css('.adev-api-items-section-header docs-icon'));
@ -84,7 +84,7 @@ describe('ApiItemsSection', () => {
});
it('should render list of all APIs of provided group', () => {
component.group = fakeFeaturedGroup;
fixture.componentRef.setInput('group', fakeFeaturedGroup);
fixture.detectChanges();
const apis = fixture.debugElement.queryAll(By.css('.adev-api-items-section-grid li'));
@ -93,7 +93,7 @@ describe('ApiItemsSection', () => {
});
it('should display deprecated icon for deprecated API', () => {
component.group = fakeFeaturedGroup;
fixture.componentRef.setInput('group', fakeFeaturedGroup);
fixture.detectChanges();
const deprecatedApiIcons = fixture.debugElement.queryAll(
@ -107,7 +107,7 @@ describe('ApiItemsSection', () => {
});
it('should display star icon for featured API', () => {
component.group = fakeFeaturedGroup;
fixture.componentRef.setInput('group', fakeFeaturedGroup);
fixture.detectChanges();
const featuredApiIcons = fixture.debugElement.queryAll(

View file

@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {ChangeDetectionStrategy, Component, Input} from '@angular/core';
import {ChangeDetectionStrategy, Component, input} from '@angular/core';
import {RouterLink} from '@angular/router';
import {ApiItemsGroup} from '../interfaces/api-items-group';
import ApiItemLabel from '../api-item-label/api-item-label.component';
@ -21,5 +21,5 @@ import {IconComponent} from '@angular/docs';
changeDetection: ChangeDetectionStrategy.OnPush,
})
export default class ApiItemsSection {
@Input({required: true}) group!: ApiItemsGroup;
group = input.required<ApiItemsGroup>();
}

View file

@ -1,8 +1,8 @@
<div class="adev-header-and-tabs docs-scroll-track-transparent">
<docs-viewer [docContent]="headerInnerHtml()" class="docs-reference-header"></docs-viewer>
<docs-viewer [docContent]="parsedDocContent().header" class="docs-reference-header"></docs-viewer>
<mat-tab-group class="docs-reference-tabs" animationDuration="0ms" mat-stretch-tabs="false" [selectedIndex]="selectedTabIndex()">
@for (tab of tabs(); track tab) {
<mat-tab-group class="docs-reference-tabs" animationDuration="0ms" mat-stretch-tabs="false" [selectedIndex]="selectedTabIndex()" (selectedIndexChange)="tabChange($event)">
@for (tab of tabs(); track tab.url) {
<mat-tab [label]="tab.title">
<div class="adev-reference-tab-body">
<docs-viewer [docContent]="tab.content"></docs-viewer>
@ -12,10 +12,10 @@
</mat-tab-group>
</div>
@if (canDisplayCards() && membersMarginTopInPx() > 0) {
@if (isApiTabActive() && membersMarginTopInPx() > 0) {
<docs-viewer
class="docs-reference-members-container"
[docContent]="membersInnerHtml()"
[docContent]="parsedDocContent().members"
[style.marginTop.px]="membersMarginTopInPx()"
(contentLoaded)="membersCardsLoaded()">
</docs-viewer>

View file

@ -7,21 +7,15 @@
*/
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
DestroyRef,
Injector,
OnInit,
ViewChild,
afterNextRender,
inject,
signal,
input,
computed,
} from '@angular/core';
import {DOCUMENT} from '@angular/common';
import {MatTabGroup, MatTabsModule} from '@angular/material/tabs';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
import {distinctUntilChanged, map} from 'rxjs/operators';
import {MatTabsModule} from '@angular/material/tabs';
import {DocContent, DocViewer} from '@angular/docs';
import {ActivatedRoute, Router} from '@angular/router';
import {ApiItemType} from './../interfaces/api-item-type';
@ -30,7 +24,6 @@ import {
API_REFERENCE_DETAILS_PAGE_HEADER_CLASS_NAME,
API_REFERENCE_DETAILS_PAGE_MEMBERS_CLASS_NAME,
API_REFERENCE_TAB_ATTRIBUTE,
API_REFERENCE_TAB_QUERY_PARAM,
API_REFERENCE_TAB_API_LABEL,
API_TAB_CLASS_NAME,
API_REFERENCE_TAB_BODY_CLASS_NAME,
@ -47,116 +40,96 @@ import {AppScroller} from '../../../app-scroller';
providers: [ReferenceScrollHandler],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export default class ApiReferenceDetailsPage implements OnInit, AfterViewInit {
@ViewChild(MatTabGroup, {static: true}) matTabGroup!: MatTabGroup;
export default class ApiReferenceDetailsPage {
private readonly activatedRoute = inject(ActivatedRoute);
private readonly destroyRef = inject(DestroyRef);
private readonly document = inject(DOCUMENT);
private readonly router = inject(Router);
private readonly scrollHandler = inject(ReferenceScrollHandler);
private readonly appScroller = inject(AppScroller);
private readonly injector = inject(Injector);
docContent = input<DocContent | undefined>();
tab = input<string | undefined>();
// aliases
ApiItemType = ApiItemType;
canDisplayCards = signal<boolean>(false);
tabs = signal<{url: string; title: string; content: string}[]>([]);
headerInnerHtml = signal<string | undefined>(undefined);
membersInnerHtml = signal<string | undefined>(undefined);
membersMarginTopInPx = this.scrollHandler.membersMarginTopInPx;
selectedTabIndex = signal(0);
// computed state
parsedDocContent = computed(() => {
// TODO: pull this logic outside of a computed where it can be tested etc.
const docContent = this.docContent();
if (docContent === undefined) {
return {
header: undefined,
members: undefined,
tabs: [],
};
}
const element = this.document.createElement('div');
element.innerHTML = docContent.contents;
// Get the innerHTML of the header element from received document.
const header = element.querySelector(API_REFERENCE_DETAILS_PAGE_HEADER_CLASS_NAME);
// Get the innerHTML of the card elements from received document.
const members = element.querySelector(API_REFERENCE_DETAILS_PAGE_MEMBERS_CLASS_NAME);
// Get the tab elements from received document.
// We're expecting that tab element will contain `tab` attribute.
const tabs = Array.from(element.querySelectorAll(`[${API_REFERENCE_TAB_ATTRIBUTE}]`)).map(
(tab) => ({
url: tab.getAttribute(API_REFERENCE_TAB_URL_ATTRIBUTE)!,
title: tab.getAttribute(API_REFERENCE_TAB_ATTRIBUTE)!,
content: tab.innerHTML,
}),
);
element.remove();
return {
header: header?.innerHTML,
members: members?.innerHTML,
tabs,
};
});
tabs = () => this.parsedDocContent().tabs;
selectedTabIndex = computed(() => {
const existingTabIdx = this.tabs().findIndex((tab) => tab.url === this.tab());
return Math.max(existingTabIdx, 0);
});
isApiTabActive = computed(() => {
const activeTabTitle = this.tabs()[this.selectedTabIndex()]?.title;
return activeTabTitle === API_REFERENCE_TAB_API_LABEL || activeTabTitle === 'CLI';
});
constructor() {
this.appScroller.disableScrolling = true;
}
ngOnInit(): void {
this.setPageContent();
afterNextRender({
write: () => {
if (this.isApiTabActive()) {
this.scrollHandler.updateMembersMarginTop(API_REFERENCE_TAB_BODY_CLASS_NAME);
}
},
});
}
ngOnDestroy() {
this.appScroller.disableScrolling = false;
}
ngAfterViewInit(): void {
this.setActiveTab();
this.listenToTabChange();
}
membersCardsLoaded(): void {
this.scrollHandler.setupListeners(API_TAB_CLASS_NAME);
}
// Fetch the content for API Reference page based on the active route.
private setPageContent(): void {
this.activatedRoute.data
.pipe(
map((data) => data['docContent']),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((doc: DocContent | undefined) => {
this.setContentForPageSections(doc);
afterNextRender(() => this.setActiveTab(), {injector: this.injector});
});
}
private listenToTabChange(): void {
this.matTabGroup.selectedIndexChange
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((index) => {
this.router.navigate([], {
relativeTo: this.activatedRoute,
queryParams: {tab: this.tabs()[index].url},
queryParamsHandling: 'merge',
});
});
}
private setContentForPageSections(doc: DocContent | undefined): void {
const element = this.document.createElement('div');
element.innerHTML = doc?.contents!;
// Get the innerHTML of the header element from received document.
const header = element.querySelector(API_REFERENCE_DETAILS_PAGE_HEADER_CLASS_NAME);
if (header) {
this.headerInnerHtml.set(header.innerHTML);
}
// Get the innerHTML of the card elements from received document.
const members = element.querySelector(API_REFERENCE_DETAILS_PAGE_MEMBERS_CLASS_NAME);
if (members) {
this.membersInnerHtml.set(members.innerHTML);
}
// Get the tab elements from received document.
// We're expecting that tab element will contain `tab` attribute.
const tabs = Array.from(element.querySelectorAll(`[${API_REFERENCE_TAB_ATTRIBUTE}]`));
this.tabs.set(
tabs.map((tab) => ({
url: tab.getAttribute(API_REFERENCE_TAB_URL_ATTRIBUTE)!,
title: tab.getAttribute(API_REFERENCE_TAB_ATTRIBUTE)!,
content: tab.innerHTML,
})),
);
element.remove();
}
private setActiveTab(): void {
this.activatedRoute.queryParamMap
.pipe(distinctUntilChanged(), takeUntilDestroyed(this.destroyRef))
.subscribe((paramsMap) => {
const selectedTabUrl = paramsMap.get(API_REFERENCE_TAB_QUERY_PARAM);
const tabIndexToSelect = this.tabs().findIndex((tab) => tab.url === selectedTabUrl);
this.selectedTabIndex.set(tabIndexToSelect < 0 ? 0 : tabIndexToSelect);
this.scrollHandler.updateMembersMarginTop(API_REFERENCE_TAB_BODY_CLASS_NAME);
const isApiTabActive =
this.tabs()[this.selectedTabIndex()]?.title === API_REFERENCE_TAB_API_LABEL ||
this.tabs()[this.selectedTabIndex()]?.title === 'CLI';
this.canDisplayCards.set(isApiTabActive);
});
tabChange(tabIndex: number) {
this.router.navigate([], {
relativeTo: this.activatedRoute,
queryParams: {tab: this.tabs()[tabIndex].url},
queryParamsHandling: 'merge',
});
}
}

View file

@ -10,14 +10,13 @@ import {
ChangeDetectionStrategy,
Component,
ElementRef,
Injector,
afterNextRender,
computed,
effect,
inject,
model,
signal,
viewChild,
afterRenderEffect,
} from '@angular/core';
import ApiItemsSection from '../api-items-section/api-items-section.component';
import {FormsModule} from '@angular/forms';
@ -40,69 +39,63 @@ export const ALL_STATUSES_KEY = 'All';
changeDetection: ChangeDetectionStrategy.OnPush,
})
export default class ApiReferenceList {
// services
private readonly apiReferenceManager = inject(ApiReferenceManager);
private readonly router = inject(Router);
filterInput = viewChild.required(TextField, {read: ElementRef});
private readonly injector = inject(Injector);
private readonly allGroups = this.apiReferenceManager.apiGroups;
constructor() {
effect(() => {
const filterInput = this.filterInput();
afterNextRender(
{
write: () => {
// Lord forgive me for I have sinned
// Use the CVA to focus when https://github.com/angular/angular/issues/31133 is implemented
if (matchMedia('(hover: hover) and (pointer:fine)').matches) {
filterInput.nativeElement.querySelector('input').focus();
}
},
},
{injector: this.injector},
);
});
effect(
() => {
const params: Params = {
'query': this.query() ? this.query() : null,
'type': this.type() ? this.type() : null,
};
this.router.navigate([], {
queryParams: params,
replaceUrl: true,
preserveFragment: true,
info: {
disableScrolling: true,
},
});
},
{allowSignalWrites: true},
);
}
// inputs
query = model<string | undefined>('');
includeDeprecated = signal(false);
type = model<string | undefined>(ALL_STATUSES_KEY);
featuredGroup = this.apiReferenceManager.featuredGroup;
// const state
itemTypes = Object.values(ApiItemType);
// state
featuredGroup = this.apiReferenceManager.featuredGroup; // THINK: this is a shortcut - why would people write this?
includeDeprecated = signal(false);
// queries
filterInput = viewChild.required(TextField, {read: ElementRef});
constructor() {
afterRenderEffect({
write: () => {
// Lord forgive me for I have sinned
// Use the CVA to focus when https://github.com/angular/angular/issues/31133 is implemented
if (matchMedia('(hover: hover) and (pointer:fine)').matches) {
this.filterInput().nativeElement.querySelector('input').focus();
}
},
});
effect(() => {
const params: Params = {
'query': this.query() ?? null,
'type': this.type() ?? null,
};
this.router.navigate([], {
queryParams: params,
replaceUrl: true,
preserveFragment: true,
info: {
disableScrolling: true,
},
});
});
}
filteredGroups = computed((): ApiItemsGroup[] => {
return this.allGroups()
const query = this.query()?.toLocaleLowerCase();
return this.apiReferenceManager
.apiGroups()
.map((group) => ({
title: group.title,
isFeatured: group.isFeatured,
id: group.id,
items: group.items.filter((apiItem) => {
return (
(this.query() !== undefined
? apiItem.title
.toLocaleLowerCase()
.includes((this.query() as string).toLocaleLowerCase())
: true) &&
(query !== undefined ? apiItem.title.toLocaleLowerCase().includes(query) : true) &&
(this.includeDeprecated() ? true : apiItem.isDeprecated === this.includeDeprecated()) &&
(this.type() === undefined ||
this.type() === ALL_STATUSES_KEY ||
@ -112,7 +105,6 @@ export default class ApiReferenceList {
}))
.filter((group) => group.items.length > 0);
});
itemTypes = Object.values(ApiItemType);
filterByItemType(itemType: ApiItemType): void {
this.type.update((currentType) => (currentType === itemType ? ALL_STATUSES_KEY : itemType));