feat(aio): migrate embedded comp to elements (#22413)

PR Close #22413
This commit is contained in:
Andrew Seguin
2018-02-28 12:05:59 -08:00
committed by Miško Hevery
parent 22b96b9690
commit 7c9b411777
69 changed files with 1021 additions and 1753 deletions
@@ -0,0 +1,33 @@
<div class="l-flex-wrap api-filter">
<aio-select (change)="setType($event.option)"
[options]="types"
[selected]="type"
[showSymbol]="true"
label="Type:">
</aio-select>
<aio-select (change)="setStatus($event.option)"
[options]="statuses"
[selected]="status"
label="Status:">
</aio-select>
<div class="form-search">
<input #filter placeholder="Filter" (input)="setQuery($event.target.value)">
<i class="material-icons">search</i>
</div>
</div>
<article class="api-list-container l-content-small docs-content">
<div *ngFor="let section of filteredSections | async" >
<h2>{{section.title}}</h2>
<ul class="api-list">
<ng-container *ngFor="let item of section.items">
<li *ngIf="item.show" class="api-item">
<a [href]="item.path"><span class="symbol {{item.docType}}"></span> {{item.title}}</a>
</li>
</ng-container>
</ul>
</div>
</article>
@@ -0,0 +1,288 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
import { ApiListComponent } from './api-list.component';
import { ApiItem, ApiSection, ApiService } from './api.service';
import { LocationService } from 'app/shared/location.service';
import { Logger } from 'app/shared/logger.service';
import { MockLogger } from 'testing/logger.service';
import { ApiListModule } from './api-list.module';
describe('ApiListComponent', () => {
let component: ApiListComponent;
let fixture: ComponentFixture<ApiListComponent>;
let sections: ApiSection[];
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ ApiListModule ],
providers: [
{ provide: ApiService, useClass: TestApiService },
{ provide: Logger, useClass: MockLogger },
{ provide: LocationService, useClass: TestLocationService }
]
});
fixture = TestBed.createComponent(ApiListComponent);
component = fixture.componentInstance;
sections = getApiSections();
});
/**
* Expectation Utility: Assert that filteredSections has the expected result for this test
* @param itemTest - return true if the item passes the match test
*
* Subscibes to `filteredSections` and performs expectation within subscription callback.
*/
function expectFilteredResult(label: string, itemTest: (item: ApiItem) => boolean) {
component.filteredSections.subscribe(filtered => {
let badItem: ApiItem|undefined;
expect(filtered.length).toBeGreaterThan(0, 'expected something');
expect(filtered.every(section => section.items.every(
item => {
const ok = item.show === itemTest(item);
if (!ok) { badItem = item; }
return ok;
}
))).toBe(true, `${label} fail: ${JSON.stringify(badItem, null, 2)}`);
});
}
describe('#filteredSections', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should return all complete sections when no criteria', () => {
let filtered: ApiSection[]|undefined;
component.filteredSections.subscribe(f => filtered = f);
expect(filtered).toEqual(sections);
});
it('item.show should be true for all queried items', () => {
component.setQuery('class');
expectFilteredResult('query: class', item => /class/.test(item.name));
});
it('item.show should be true for every item in section when query matches section name', () => {
component.setQuery('core');
component.filteredSections.subscribe(filtered => {
expect(filtered.length).toBe(1, 'only one section');
expect(filtered[0].name).toBe('core');
expect(filtered[0].items.every(item => !!item.show)).toBe(true, 'all core items shown');
});
});
it('item.show should be true for items with selected status', () => {
component.setStatus({value: 'stable', title: 'Stable'});
expectFilteredResult('status: stable', item => item.stability === 'stable');
});
it('item.show should be true for items with "security-risk" status when selected', () => {
component.setStatus({value: 'security-risk', title: 'Security Risk'});
expectFilteredResult('status: security-risk', item => item.securityRisk);
});
it('item.show should be true for items of selected type', () => {
component.setType({value: 'class', title: 'Class'});
expectFilteredResult('type: class', item => item.docType === 'class');
});
it('should have no sections and no items when no match', () => {
component.setQuery('fizbuzz');
component.filteredSections.subscribe(filtered => {
expect(filtered.length).toBe(0, 'expected no sections');
});
});
});
describe('initial critera from location', () => {
let locationService: TestLocationService;
beforeEach(() => {
locationService = <any> fixture.componentRef.injector.get(LocationService);
});
function expectOneItem(name: string, section: string, type: string, stability: string) {
fixture.detectChanges();
component.filteredSections.subscribe(filtered => {
expect(filtered.length).toBe(1, 'sections');
expect(filtered[0].name).toBe(section, 'section name');
const items = filtered[0].items.filter(item => item.show);
expect(items.length).toBe(1, 'items');
const item = items[0];
const badItem = 'Wrong item: ' + JSON.stringify(item, null, 2);
expect(item.docType).toBe(type, badItem);
expect(item.stability).toBe(stability, badItem);
expect(item.name).toBe(name, badItem);
});
}
it('should filter as expected for ?query', () => {
locationService.query = {query: '_3'};
expectOneItem('class_3', 'core', 'class', 'experimental');
});
it('should filter as expected for ?status', () => {
locationService.query = {status: 'deprecated'};
expectOneItem('function_1', 'core', 'function', 'deprecated');
});
it('should filter as expected when status is security-risk', () => {
locationService.query = {status: 'security-risk'};
fixture.detectChanges();
expectFilteredResult('security-risk', item => item.securityRisk);
});
it('should filter as expected for ?type', () => {
locationService.query = {type: 'pipe'};
expectOneItem('pipe_1', 'common', 'pipe', 'stable');
});
it('should filter as expected for ?query&status&type', () => {
locationService.query = {
query: 's_1',
status: 'experimental',
type: 'class'
};
fixture.detectChanges();
expectOneItem('class_1', 'common', 'class', 'experimental');
});
it('should ignore case for ?query&status&type', () => {
locationService.query = {
query: 'S_1',
status: 'ExperiMental',
type: 'CLASS'
};
fixture.detectChanges();
expectOneItem('class_1', 'common', 'class', 'experimental');
});
});
describe('location path after criteria change', () => {
let locationService: TestLocationService;
beforeEach(() => {
locationService = <any> fixture.componentRef.injector.get(LocationService);
});
it('should have query', () => {
component.setQuery('foo');
// `setSearch` 2nd param is a query/search params object
const search = locationService.setSearch.calls.mostRecent().args[1];
expect(search.query).toBe('foo');
});
it('should keep last of multiple query settings (in lowercase)', () => {
component.setQuery('foo');
component.setQuery('fooBar');
const search = locationService.setSearch.calls.mostRecent().args[1];
expect(search.query).toBe('foobar');
});
it('should have query, status, and type', () => {
component.setQuery('foo');
component.setStatus({value: 'stable', title: 'Stable'});
component.setType({value: 'class', title: 'Class'});
const search = locationService.setSearch.calls.mostRecent().args[1];
expect(search.query).toBe('foo');
expect(search.status).toBe('stable');
expect(search.type).toBe('class');
});
});
});
////// Helpers ////////
class TestLocationService {
query: {[index: string]: string } = {};
setSearch = jasmine.createSpy('setSearch');
search() { return this.query; }
}
class TestApiService {
sectionsSubject = new BehaviorSubject(getApiSections());
sections = this.sectionsSubject.asObservable();
}
// tslint:disable:quotemark
const apiSections: ApiSection[] = [
{
"name": "common",
"title": "common",
"items": [
{
"name": "class_1",
"title": "Class 1",
"path": "api/common/class_1",
"docType": "class",
"stability": "experimental",
"securityRisk": false
},
{
"name": "class_2",
"title": "Class 2",
"path": "api/common/class_2",
"docType": "class",
"stability": "stable",
"securityRisk": false
},
{
"name": "directive_1",
"title": "Directive 1",
"path": "api/common/directive_1",
"docType": "directive",
"stability": "stable",
"securityRisk": true
},
{
"name": "pipe_1",
"title": "Pipe 1",
"path": "api/common/pipe_1",
"docType": "pipe",
"stability": "stable",
"securityRisk": true
},
]
},
{
"name": "core",
"title": "core",
"items": [
{
"name": "class_3",
"title": "Class 3",
"path": "api/core/class_3",
"docType": "class",
"stability": "experimental",
"securityRisk": false
},
{
"name": "function_1",
"title": "Function 1",
"path": "api/core/function 1",
"docType": "function",
"stability": "deprecated",
"securityRisk": true
},
{
"name": "const_1",
"title": "Const 1",
"path": "api/core/const_1",
"docType": "const",
"stability": "stable",
"securityRisk": false
}
]
}
];
function getApiSections() { return apiSections; }
@@ -0,0 +1,176 @@
/*
* API List & Filter Component
*
* A page that displays a formatted list of the public Angular API entities.
* Clicking on a list item triggers navigation to the corresponding API entity document.
* Can add/remove API entity links based on filter settings.
*/
import { Component, ElementRef, OnInit, ViewChild } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { ReplaySubject } from 'rxjs/ReplaySubject';
import { combineLatest } from 'rxjs/observable/combineLatest';
import { LocationService } from 'app/shared/location.service';
import { ApiSection, ApiService } from './api.service';
import { Option } from 'app/shared/select/select.component';
class SearchCriteria {
query? = '';
status? = 'all';
type? = 'all';
}
@Component({
selector: 'aio-api-list',
templateUrl: './api-list.component.html',
})
export class ApiListComponent implements OnInit {
filteredSections: Observable<ApiSection[]>;
showStatusMenu = false;
showTypeMenu = false;
private criteriaSubject = new ReplaySubject<SearchCriteria>(1);
private searchCriteria = new SearchCriteria();
status: Option;
type: Option;
// API types
types: Option[] = [
{ value: 'all', title: 'All' },
{ value: 'class', title: 'Class' },
{ value: 'const', title: 'Const'},
{ value: 'decorator', title: 'Decorator' },
{ value: 'directive', title: 'Directive' },
{ value: 'enum', title: 'Enum' },
{ value: 'function', title: 'Function' },
{ value: 'interface', title: 'Interface' },
{ value: 'pipe', title: 'Pipe'},
{ value: 'type-alias', title: 'Type Alias' }
];
statuses: Option[] = [
{ value: 'all', title: 'All' },
{ value: 'stable', title: 'Stable' },
{ value: 'deprecated', title: 'Deprecated' },
{ value: 'experimental', title: 'Experimental' },
{ value: 'security-risk', title: 'Security Risk' }
];
@ViewChild('filter') queryEl: ElementRef;
constructor(
private apiService: ApiService,
private locationService: LocationService) { }
ngOnInit() {
this.filteredSections = combineLatest(
this.apiService.sections,
this.criteriaSubject,
(sections, criteria) => {
return sections.filter(section => this.filterSection(section, criteria));
}
);
this.initializeSearchCriteria();
}
// Todo: may need to debounce as the original did
// although there shouldn't be any perf consequences if we don't
setQuery(query: string) {
this.setSearchCriteria({query: (query || '').toLowerCase().trim() });
}
setStatus(status: Option) {
this.toggleStatusMenu();
this.status = status;
this.setSearchCriteria({status: status.value});
}
setType(type: Option) {
this.toggleTypeMenu();
this.type = type;
this.setSearchCriteria({type: type.value});
}
toggleStatusMenu() {
this.showStatusMenu = !this.showStatusMenu;
}
toggleTypeMenu() {
this.showTypeMenu = !this.showTypeMenu;
}
//////// Private //////////
private filterSection(section: ApiSection, { query, status, type }: SearchCriteria) {
let showSection = false;
section.items.forEach(item => {
item.show = matchesType() && matchesStatus() && matchesQuery();
// show section if any of its items will be shown
showSection = showSection || item.show;
function matchesQuery() {
return !query ||
section.name.indexOf(query) !== -1 ||
item.name.indexOf(query) !== -1;
}
function matchesStatus() {
return status === 'all' ||
status === item.stability ||
(status === 'security-risk' && item.securityRisk);
};
function matchesType() {
return type === 'all' || type === item.docType;
}
});
return showSection;
}
// Get initial search criteria from URL search params
private initializeSearchCriteria() {
const {query, status, type} = this.locationService.search();
const q = (query || '').toLowerCase();
// Hack: can't bind to query because input cursor always forced to end-of-line.
this.queryEl.nativeElement.value = q;
this.status = this.statuses.find(x => x.value === status) || this.statuses[0];
this.type = this.types.find(x => x.value === type) || this.types[0];
this.searchCriteria = {
query: q,
status: this.status.value,
type: this.type.value
};
this.criteriaSubject.next(this.searchCriteria);
}
private setLocationSearch() {
const {query, status, type} = this.searchCriteria;
const params = {
query: query ? query : undefined,
status: status !== 'all' ? status : undefined,
type: type !== 'all' ? type : undefined
};
this.locationService.setSearch('API Search', params);
}
private setSearchCriteria(criteria: SearchCriteria) {
this.criteriaSubject.next(Object.assign(this.searchCriteria, criteria));
this.setLocationSearch();
}
}
@@ -0,0 +1,17 @@
import { NgModule, Type } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HttpClientModule } from '@angular/common/http';
import { SharedModule } from '../../shared/shared.module';
import { ApiListComponent } from './api-list.component';
import { ApiService } from './api.service';
import { WithCustomElementComponent } from '../element-registry';
@NgModule({
imports: [ CommonModule, SharedModule, HttpClientModule ],
declarations: [ ApiListComponent ],
entryComponents: [ ApiListComponent ],
providers: [ ApiService ]
})
export class ApiListModule implements WithCustomElementComponent {
customElementComponent: Type<any> = ApiListComponent;
}
@@ -0,0 +1,115 @@
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { Injector } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Logger } from 'app/shared/logger.service';
import { ApiService } from './api.service';
describe('ApiService', () => {
let injector: Injector;
let service: ApiService;
let httpMock: HttpTestingController;
beforeEach(() => {
injector = TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
ApiService,
{ provide: Logger, useClass: TestLogger }
]
});
service = injector.get(ApiService);
httpMock = injector.get(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('should not immediately connect to the server', () => {
httpMock.expectNone({});
});
it('subscribers should be completed/unsubscribed when service destroyed', () => {
let completed = false;
service.sections.subscribe(
undefined,
undefined,
() => completed = true
);
service.ngOnDestroy();
expect(completed).toBe(true);
// Stop `httpMock.verify()` from complaining.
httpMock.expectOne({});
});
describe('#sections', () => {
it('first subscriber should fetch sections', () => {
const data = [{name: 'a', title: 'A', items: []}, {name: 'b', title: 'B', items: []}];
service.sections.subscribe(sections => {
expect(sections).toEqual(data);
});
httpMock.expectOne({}).flush(data);
});
it('second subscriber should get previous sections and NOT trigger refetch', () => {
const data = [{name: 'a', title: 'A', items: []}, {name: 'b', title: 'B', items: []}];
let subscriptions = 0;
service.sections.subscribe(sections => {
subscriptions++;
expect(sections).toEqual(data);
});
service.sections.subscribe(sections => {
subscriptions++;
expect(sections).toEqual(data);
});
httpMock.expectOne({}).flush(data);
});
});
describe('#fetchSections', () => {
it('should connect to the server w/ expected URL', () => {
service.fetchSections();
httpMock.expectOne('generated/docs/api/api-list.json');
});
it('should refresh the #sections observable w/ new content on second call', () => {
let call = 0;
let data = [{name: 'a', title: 'A', items: []}, {name: 'b', title: 'B', items: []}];
service.sections.subscribe(sections => {
// called twice during this test
// (1) during subscribe
// (2) after refresh
expect(sections).toEqual(data, 'call ' + call++);
});
httpMock.expectOne({}).flush(data);
// refresh/refetch
data = [{name: 'c', title: 'C', items: []}];
service.fetchSections();
httpMock.expectOne({}).flush(data);
expect(call).toBe(2, 'should be called twice');
});
});
});
class TestLogger {
log = jasmine.createSpy('log');
error = jasmine.createSpy('error');
}
@@ -0,0 +1,85 @@
import { Injectable, OnDestroy } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { ReplaySubject } from 'rxjs/ReplaySubject';
import { Subject } from 'rxjs/Subject';
import 'rxjs/add/operator/do';
import 'rxjs/add/operator/map';
import 'rxjs/add/operator/takeUntil';
import { Logger } from 'app/shared/logger.service';
import { DOC_CONTENT_URL_PREFIX } from 'app/documents/document.service';
export interface ApiItem {
name: string;
title: string;
path: string;
docType: string;
stability: string;
securityRisk: boolean;
show?: boolean;
}
export interface ApiSection {
name: string;
title: string;
items: ApiItem[];
}
@Injectable()
export class ApiService implements OnDestroy {
private apiBase = DOC_CONTENT_URL_PREFIX + 'api/';
private apiListJsonDefault = 'api-list.json';
private firstTime = true;
private onDestroy = new Subject();
private sectionsSubject = new ReplaySubject<ApiSection[]>(1);
private _sections = this.sectionsSubject.takeUntil(this.onDestroy);
/**
* Return a cached observable of API sections from a JSON file.
* API sections is an array of Angular top modules and metadata about their API documents (items).
*/
get sections() {
if (this.firstTime) {
this.firstTime = false;
this.fetchSections(); // TODO: get URL for fetchSections by configuration?
// makes sectionsSubject hot; subscribe ensures stays alive (always refCount > 0);
this._sections.subscribe(sections => this.logger.log('ApiService got API sections') );
}
return this._sections;
};
constructor(private http: HttpClient, private logger: Logger) { }
ngOnDestroy() {
this.onDestroy.next();
}
/**
* Fetch API sections from a JSON file.
* API sections is an array of Angular top modules and metadata about their API documents (items).
* Updates `sections` observable
*
* @param {string} [src] - Name of the api list JSON file
*/
fetchSections(src?: string) {
// TODO: get URL by configuration?
const url = this.apiBase + (src || this.apiListJsonDefault);
this.http.get<ApiSection[]>(url)
.takeUntil(this.onDestroy)
.do(() => this.logger.log(`Got API sections from ${url}`))
.subscribe(
sections => this.sectionsSubject.next(sections),
(err: HttpErrorResponse) => {
// Todo: handle error
this.logger.error(err);
throw err; // rethrow for now.
}
);
}
}