feat(aio): add API list page (#14899)

This commit is contained in:
Ward Bell 2017-03-08 13:08:19 -08:00 committed by Chuck Jazdzewski
parent 174d4c8ef7
commit 5ad5301a3e
11 changed files with 4453 additions and 2 deletions

1
aio/content/marketing/api.html Executable file
View File

@ -0,0 +1 @@
<aio-api-list></aio-api-list>

View File

@ -16,6 +16,7 @@ import { Platform } from '@angular/material/core';
import 'rxjs/add/operator/first';
import { AppComponent } from 'app/app.component';
import { ApiService } from 'app/embedded/api/api.service';
import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component';
import { embeddedComponents, EmbeddedComponents } from 'app/embedded';
import { Logger } from 'app/shared/logger.service';
@ -48,6 +49,7 @@ import { LinkDirective } from 'app/shared/link.directive';
LinkDirective,
],
providers: [
ApiService,
EmbeddedComponents,
Logger,
Location,

View File

@ -57,7 +57,8 @@ export class DocumentService {
return subject.asObservable();
}
private computePath(url) {
private computePath(url: string) {
url = url.match(/[^#?]*/)[0]; // strip off fragment and query
url = '/' + url;
url = url.endsWith('/') ? url + 'index' : url;
return 'content/docs' + url + '.json';

View File

@ -0,0 +1,42 @@
<div class="l-flex-wrap banner is-plain api-filter">
<div class="form-select-menu">
<button class="form-select-button has-symbol" (click)="toggleTypeMenu()">
<strong>Type:</strong><span class="symbol {{type.name}}"></span>{{type.title}}
</button>
<ul class="form-select-dropdown" *ngIf="showTypeMenu">
<li *ngFor="let t of types" (click)="setType(t)" [class.selected]="t === type">
<span class="symbol {{t.name}}"></span>{{t.title}}
</li>
</ul>
</div>
<div class="form-select-menu">
<button class="form-select-button" (click)="toggleStatusMenu()">
<strong>Status:</strong>{{status.title}}
</button>
<ul class="form-select-dropdown" *ngIf="showStatusMenu">
<li *ngFor="let s of statuses" (click)="setStatus(s)" [class.selected]="s === status">
{{s.title}}
</li>
</ul>
</div>
<div class="form-search">
<i class="material-icons">search</i>
<input #filter placeholder="Filter" (input)="setQuery($event.target.value)">
</div>
</div>
<article class="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>

View File

@ -0,0 +1,512 @@
/* HACKED FROM aio v.1 */
// TODO:refactor out those parts that should be shared SASS files and import them here
/*
* Metrics
*
* Metrics based on material design 8pt unit
*/
$unit: 8px;
$phone-breakpoint: 480px;
$tablet-breakpoint: 800px;
/*
* Layer Stacking
*
* The approved range that can be used for layering (z-indexes)
*/
$layer-1: 1;
$layer-2: 2;
$layer-3: 3;
$layer-4: 4;
$layer-5: 5;
/*
* Colors from original _colors.scss
*/
$white: #FFFFFF;
$black: #000000;
$amber-700: #FFA000;
$blue-400: #42A5F5;
$blue-500: #2196F3;
$blue-600: #1E88E5;
$blue-800: #1565C0;
$blue-grey-50: #ECEFF1;
$blue-grey-100: #CFD8DC;
$blue-grey-500: #607D8B;
$blue-grey-600: #546E7A;
$green-500: #4CAF50;
$green-800: #2E7D32;
$light-green-600: #7CB342;
$pink-600: #D81B60;
$purple-600: #8E24AA;
$teal-500: #009688;
$lightgrey: #F5F6F7;
/*
* Layout
*/
.docs-content {
position: relative;
}
.l-content-small {
padding: $unit * 6;
max-width: 1100px;
margin: 0;
@media handheld and (max-width: $phone-breakpoint),
screen and (max-device-width: $phone-breakpoint),
screen and (max-width: $tablet-breakpoint) {
padding: 0;
padding-top: ($unit * 3);
}
}
.l-flex-wrap {
display: flex;
flex-wrap: wrap;
}
/*
* Banner
*/
.banner {
background: rgba($blue-grey-50, .24);
border-bottom: 1px solid $blue-grey-50;
box-sizing: border-box;
font-size: 18px;
font-weight: 200;
padding: ($unit * 4) ($unit * 6);
min-height: 97px;
include respond-to('mobile') {
padding: ($unit * 2);
}
&.is-plain {
background: $white;
height: auto;
overflow: visible;
}
p, .text-body {
color: $blue-grey-500;
font-size: 18px;
line-height: 32px;
margin: 0;
}
}
/*
* SEARCH BAR
*/
.form-search {
position: relative;
input {
box-shadow: 0 2px 2px rgba($black, 0.24), 0 0 2px rgba($black, 0.12);
box-sizing: border-box;
border: 1px solid $white;
color: $blue-600;
font-size: 16px;
height: $unit * 4;
line-height: $unit * 4;
outline: none;
padding: 0 ($unit *2) 0 ($unit * 4);
transition: all .2s;
// PLACEHOLDER TEXT
&::-webkit-input-placeholder { /* Chrome/Opera/Safari */
color: $blue-grey-100;
font-size: 14px;
}
&::-moz-placeholder { /* Firefox 19+ */
color: $blue-grey-100;
font-size: 14px;
}
&:-ms-input-placeholder { /* IE 10+ */
color: $blue-grey-100;
font-size: 14px;
}
&:-moz-placeholder { /* Firefox 18- */
color: $blue-grey-100;
font-size: 14px;
}
&:focus {
border: 1px solid $blue-400;
box-shadow: 0 2px 2px rgba($blue-400, 0.24), 0 0 2px rgba($blue-400, 0.12);
}
}
.material-icons {
color: $blue-grey-100;
font-size: 20px;
left: $unit;
position: absolute;
top: 6px;
z-index: $layer-1;
}
}
/*
* Select Menu
*/
$form-select-width: 200px;
.form-select-menu {
position: relative;
width: $form-select-width;
}
.form-select-button {
background: $white;
box-shadow: 0 2px 2px rgba($black, 0.24), 0 0 2px rgba($black, 0.12);
box-sizing: border-box;
border: 1px solid $white;
color: $blue-grey-600;
font-size: 12px;
font-weight: 400;
height: $unit * 4;
line-height: $unit * 4;
outline: none;
padding: 0 ($unit * 2);
text-align: left;
width: $form-select-width - ($unit * 2);
strong {
font-weight: 600;
margin-right: $unit;
text-transform: uppercase;
}
&.has-symbol {
.symbol {
margin-right: $unit;
}
}
}
.form-select-dropdown {
background: $white;
box-shadow: 0 16px 16px rgba($black, 0.24), 0 0 16px rgba($black, 0.12);
border-radius: 4px;
left: -$unit;
list-style-type: none;
margin: 0;
padding: $unit 0;
position: absolute;
top: -$unit;
width: 200px;
z-index: $layer-2;
li {
cursor: pointer;
font-size: 14px;
line-height: $unit * 4;
margin: 0;
padding: 0 ($unit * 2) 0 ($unit * 5);
position: relative;
transition: all .2s;
&:hover {
background: $blue-grey-50;
color: $blue-500;
}
&.selected {
background-color: $blue-grey-100;
}
.symbol {
left: $unit * 2;
position: absolute;
top: $unit;
z-index: $layer-5;
}
}
}
/*
* API Symbols
*
*/
/*
* Variables
*/
$api-symbols: (
all: (
content: ' ',
background: $white
),
decorator: (
content: '@',
background: $blue-800
),
directive: (
content: 'D',
background: $pink-600
),
pipe: (
content: 'P',
background: $blue-grey-600
),
class: (
content: 'C',
background: $blue-500
),
interface: (
content: 'I',
background: $teal-500
),
function: (
content: 'F',
background: $green-500
),
enum: (
content: 'E',
background: $amber-700
),
const: (
content: 'K',
background: $purple-600
),
type-alias: (
content: 'T',
background: $light-green-600
)
);
/*
* Symbol Class
*/
.symbol {
border-radius: 2px;
box-shadow: 0 1px 2px rgba($black, .24);
color: $white;
display: inline-block;
font-size: 10px;
font-weight: 600;
line-height: $unit * 2;
text-align: center;
width: $unit * 2;
// SYMBOL TYPES
@each $name, $symbol in $api-symbols {
&.#{$name} {
background: map-get($symbol, background);
&:before {
content: map-get($symbol, content);
}
}
}
}
/*
* API Home Page
*
*/
/*
* API Filter Menu
*
*/
.api-filter {
.form-select-menu {
float: left;
}
.form-search {
float: left;
}
}
/*
* API Class List
*
*/
.docs-content .api-list {
list-style: none;
margin: 0 0 ($unit * 6) 0;
padding: 0;
overflow: hidden;
li {
font-size: 14px;
margin: 0 0 ($unit * 2) 0;
line-height: 14px;
padding: 0;
float: left;
width: 33%;
min-width: 220px;
text-overflow: ellipsis;
white-space: nowrap;
.symbol {
margin-right: $unit;
}
a {
color: $blue-grey-600;
display: inline-block;
line-height: $unit * 2;
padding: 0 ($unit * 2) 0 0;
text-decoration: none;
transition: all .3s;
&:hover {
background: $blue-grey-50;
color: $blue-500;
}
}
}
}
.docs-content .h2-api-docs,
.docs-content .h2-api-docs:first-of-type {
font-size: 18px;
line-height: 24px;
margin-top: 0;
}
.code-links {
a {
code, .api-doc-code {
color: #1E88E5 !important;
}
}
}
.openParens {
margin-top: 15px;
}
.endParens {
margin-bottom: 20px !important;
}
p {
&.selector {
margin: 0;
}
&.location-badge {
margin: 0 0 16px 16px !important;
}
.api-doc-code {
border-bottom: 0px;
:hover {
border-bottom: none;
}
}
}
.row-margin {
margin-bottom: 36px;
h2 {
line-height: 28px;
}
}
.code-margin {
margin-bottom: $unit;
}
.hr-margin {
display: block;
height: 1px;
border: 0;
border-top: 1px solid $lightgrey;
margin-top: 15px;
margin-bottom: 20px;
padding: 0;
}
.no-bg {
background: none;
padding: 0;
}
.no-bg-with-indent {
padding-top: 0;
padding-bottom: 0;
padding-left: 16px;
margin-top: 6px;
margin-bottom: 0;
background: none;
}
.code-background {
padding: 0 5px 0;
span.pln {
color: #1E88E5 !important;
}
}
.code-anchor {
cursor: pointer;
text-decoration: none;
// Override highlight.js
.kwd {
color: #1E88E5 !important;
}
&:hover {
text-decoration: underline;
}
}
.api-doc-code {
font-size: 14px;
color: #1a2326;
// the last .pln (white space) creates additional spacing between sections of the api doc. Remove it.
&.no-pln {
.pln:last-child {
display: none;
}
}
}
@media screen and (max-width: 600px) {
.docs-content {
// Overrides display flex from angular material.
// This was added because Safari doesn't play nice with layout="column".
// Look of API doc in Chrome and Firefox remains the same, and is fixed for Safari.
.layout-xs-column {
display: block !important;
}
}
.api-doc-code {
font-size: 12px;
}
p.location-badge {
position: relative;
font-size: 11px;
}
}

View File

@ -0,0 +1,181 @@
/*
* 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 { ApiItem, ApiSection, ApiService } from './api.service';
interface MenuItem {
name: string;
title: string;
}
class SearchCriteria {
query? = '';
status? = 'all';
type? = 'all';
}
@Component({
selector: 'aio-api-list',
templateUrl: './api-list.component.html',
styleUrls: ['./api-list.component.scss']
})
export class ApiListComponent implements OnInit {
filteredSections: Observable<ApiSection[]>;
showStatusMenu = false;
showTypeMenu = false;
private criteriaSubject = new ReplaySubject<SearchCriteria>(1);
private searchCriteria = new SearchCriteria();
status: MenuItem;
type: MenuItem;
// API types
types: MenuItem[] = [
{ name: 'all', title: 'All' },
{ name: 'directive', title: 'Directive' },
{ name: 'pipe', title: 'Pipe'},
{ name: 'decorator', title: 'Decorator' },
{ name: 'class', title: 'Class' },
{ name: 'interface', title: 'Interface' },
{ name: 'function', title: 'Function' },
{ name: 'enum', title: 'Enum' },
{ name: 'type-alias', title: 'Type Alias' },
{ name: 'const', title: 'Const'}
];
statuses: MenuItem[] = [
{ name: 'all', title: 'All' },
{ name: 'stable', title: 'Stable' },
{ name: 'deprecated', title: 'Deprecated' },
{ name: 'experimental', title: 'Experimental' },
{ name: '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: MenuItem) {
this.toggleStatusMenu();
this.status = status;
this.setSearchCriteria({status: status.name});
}
setType(type: MenuItem) {
this.toggleTypeMenu();
this.type = type;
this.setSearchCriteria({type: type.name});
}
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.name === status) || this.statuses[0];
this.type = this.types.find(x => x.name === type) || this.types[0];
this.searchCriteria = {
query: q,
status: this.status.name,
type: this.type.name
};
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();
}
}

View File

@ -0,0 +1,105 @@
import { Injectable, OnDestroy } from '@angular/core';
import { Http } from '@angular/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';
export interface ApiItem {
title: string;
path: string;
docType: string;
stability: string;
secure: string;
securityRisk: boolean;
barrel: string;
name?: string;
show?: boolean;
}
export interface ApiSection {
name: string;
title: string;
items: ApiItem[];
}
@Injectable()
export class ApiService implements OnDestroy {
private apiBase = 'content/docs/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: Http, 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(url)
.takeUntil(this.onDestroy)
.map(response => {
const sections = response.json();
return Object.keys(sections).map(title => {
const items = sections[title] as ApiItem[];
items.forEach(normalizeItem);
return { name: title.toLowerCase(), title, items };
});
})
.do(() => this.logger.log(`Got API sections from ${url}`))
.subscribe(
sections => this.sectionsSubject.next(sections),
err => {
// Todo: handle error
this.logger.error(err);
throw err; // rethrow for now.
}
);
function normalizeItem(item: ApiItem) {
item.name = item.title.toLowerCase();
// convert 'secure' property to boolean `securityRisk`
item.securityRisk = item.secure !== 'false';
// 'let' and 'var' doc types should be treated as 'const'
const docType = item.docType;
if (docType === 'let' || docType === 'var') {
item.docType = 'const';
}
}
}
}

View File

@ -1,9 +1,10 @@
import { ApiListComponent } from './api/api-list.component';
import { CodeExampleComponent } from './code-example.component';
import { DocTitleComponent } from './doc-title.component';
/** Components that can be embedded in docs such as CodeExampleComponent, LiveExampleComponent,... */
export const embeddedComponents: any[] = [
CodeExampleComponent, DocTitleComponent
ApiListComponent, CodeExampleComponent, DocTitleComponent
];
/** Injectable class w/ property returning components that can be embedded in docs */

View File

@ -119,4 +119,12 @@ describe('LocationService', () => {
]);
});
});
describe('search', () => {
it('should ...', () => { });
});
describe('setSearch', () => {
it('should ...', () => { });
});
});

View File

@ -20,6 +20,7 @@ export class LocationService {
});
}
// TODO?: ignore if url-without-hash-or-search matches current location?
go(url: string) {
this.location.go(url);
this.urlSubject.next(url);
@ -28,4 +29,34 @@ export class LocationService {
private stripLeadingSlashes(url: string) {
return url.replace(/^\/+/, '');
}
search(): { [index: string]: string; } {
const search = {};
const path = this.location.path();
const q = path.indexOf('?');
if (q > -1) {
try {
const params = path.substr(q + 1).split('&');
params.forEach(p => {
const pair = p.split('=');
search[pair[0]] = decodeURIComponent(pair[1]);
});
} catch (e) { /* don't care */ }
}
return search;
}
setSearch(label: string, params: {}) {
if (!window || !window.history) { return; }
const search = Object.keys(params).reduce((acc, key) => {
const value = params[key];
// tslint:disable-next-line:triple-equals
return value == undefined ? acc :
acc += (acc ? '&' : '?') + `${key}=${encodeURIComponent(value)}`;
}, '');
// this.location.replaceState doesn't let you set the history stack label
window.history.replaceState({}, 'API Search', window.location.pathname + search);
}
}

File diff suppressed because it is too large Load Diff