feat(aio): implement resources with resources.json
This commit is contained in:

committed by
Pete Bacon Darwin

parent
46b0c7a18c
commit
196203f6d7
@ -1,25 +1,22 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { Contributor } from './contributors.model';
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
|
||||
import { ContributorGroup } from './contributors.model';
|
||||
import { ContributorService } from './contributor.service';
|
||||
|
||||
@Component({
|
||||
selector: `aio-contributor-list`,
|
||||
selector: 'aio-contributor-list',
|
||||
template: `
|
||||
<section *ngFor="let group of groups" class="grid-fluid">
|
||||
<h4 class="title">{{group}}</h4>
|
||||
<aio-contributor *ngFor="let person of contributorGroups[group]" [person]="person"></aio-contributor>
|
||||
<section *ngFor="let group of groups | async" class="grid-fluid">
|
||||
<h4 class="title">{{group.name}}</h4>
|
||||
<aio-contributor *ngFor="let person of group.contributors" [person]="person"></aio-contributor>
|
||||
</section>`
|
||||
})
|
||||
export class ContributorListComponent implements OnInit {
|
||||
contributorGroups = new Map<string, Contributor[]>();
|
||||
groups: string[];
|
||||
export class ContributorListComponent {
|
||||
groups: Observable<ContributorGroup[]>;
|
||||
|
||||
constructor(private contributorService: ContributorService) { }
|
||||
|
||||
ngOnInit() {
|
||||
this.contributorService.contributors.subscribe(cgs => {
|
||||
this.groups = ['Lead', 'Google', 'Community'];
|
||||
this.contributorGroups = cgs;
|
||||
});
|
||||
constructor(private contributorService: ContributorService) {
|
||||
this.groups = this.contributorService.contributors;
|
||||
}
|
||||
}
|
||||
|
118
aio/src/app/embedded/contributor/contributor.service.spec.ts
Normal file
118
aio/src/app/embedded/contributor/contributor.service.spec.ts
Normal file
@ -0,0 +1,118 @@
|
||||
import { ReflectiveInjector } from '@angular/core';
|
||||
import { Http, ConnectionBackend, RequestOptions, BaseRequestOptions, Response, ResponseOptions } from '@angular/http';
|
||||
import { MockBackend } from '@angular/http/testing';
|
||||
|
||||
import { ContributorService } from './contributor.service';
|
||||
import { Contributor, ContributorGroup } from './contributors.model';
|
||||
import { Logger } from 'app/shared/logger.service';
|
||||
|
||||
describe('ContributorService', () => {
|
||||
|
||||
let injector: ReflectiveInjector;
|
||||
let backend: MockBackend;
|
||||
let contribService: ContributorService;
|
||||
|
||||
function createResponse(body: any) {
|
||||
return new Response(new ResponseOptions({ body: JSON.stringify(body) }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
injector = ReflectiveInjector.resolveAndCreate([
|
||||
ContributorService,
|
||||
{ provide: ConnectionBackend, useClass: MockBackend },
|
||||
{ provide: RequestOptions, useClass: BaseRequestOptions },
|
||||
Http,
|
||||
Logger
|
||||
]);
|
||||
|
||||
backend = injector.get(ConnectionBackend);
|
||||
contribService = injector.get(ContributorService);
|
||||
});
|
||||
|
||||
it('should be creatable', () => {
|
||||
expect(contribService).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should make a single connection to the server', () => {
|
||||
expect(backend.connectionsArray.length).toEqual(1);
|
||||
expect(backend.connectionsArray[0].request.url).toEqual('content/contributors.json');
|
||||
});
|
||||
|
||||
describe('#contributors', () => {
|
||||
|
||||
let contribs: ContributorGroup[];
|
||||
let testData: any;
|
||||
|
||||
beforeEach(() => {
|
||||
testData = getTestContribs();
|
||||
backend.connectionsArray[0].mockRespond(createResponse(testData));
|
||||
contribService.contributors.subscribe(results => contribs = results);
|
||||
});
|
||||
|
||||
it('contributors observable should complete', () => {
|
||||
let completed = false;
|
||||
contribService.contributors.subscribe(null, null, () => completed = true);
|
||||
expect(true).toBe(true, 'observable completed');
|
||||
});
|
||||
|
||||
it('should reshape the contributor json to expected result', () => {
|
||||
const groupNames = contribs.map(g => g.name);
|
||||
expect(groupNames).toEqual(['Lead', 'Google', 'Community']);
|
||||
});
|
||||
|
||||
it('should have expected "Lead" contribs in order', () => {
|
||||
const leads = contribs[0];
|
||||
const actualLeadNames = leads.contributors.map(l => l.name).join(',');
|
||||
const expectedLeadNames = [testData.igor, testData.misko, testData.naomi].map(l => l.name).join(',');
|
||||
expect(actualLeadNames).toEqual(expectedLeadNames);
|
||||
});
|
||||
});
|
||||
|
||||
it('should do WHAT(?) if the request fails');
|
||||
});
|
||||
|
||||
function getTestContribs() {
|
||||
// tslint:disable:quotemark
|
||||
return {
|
||||
"misko": {
|
||||
"name": "Miško Hevery",
|
||||
"picture": "misko.jpg",
|
||||
"twitter": "mhevery",
|
||||
"website": "http://misko.hevery.com",
|
||||
"bio": "Miško Hevery is the creator of AngularJS framework.",
|
||||
"group": "Lead"
|
||||
},
|
||||
"igor": {
|
||||
"name": "Igor Minar",
|
||||
"picture": "igor-minar.jpg",
|
||||
"twitter": "IgorMinar",
|
||||
"website": "https://google.com/+IgorMinar",
|
||||
"bio": "Igor is a software engineer at Google.",
|
||||
"group": "Lead"
|
||||
},
|
||||
"kara": {
|
||||
"name": "Kara Erickson",
|
||||
"picture": "kara-erickson.jpg",
|
||||
"twitter": "karaforthewin",
|
||||
"website": "https://github.com/kara",
|
||||
"bio": "Kara is a software engineer on the Angular team at Google and a co-organizer of the Angular-SF Meetup. ",
|
||||
"group": "Google"
|
||||
},
|
||||
"jeffcross": {
|
||||
"name": "Jeff Cross",
|
||||
"picture": "jeff-cross.jpg",
|
||||
"twitter": "jeffbcross",
|
||||
"website": "https://twitter.com/jeffbcross",
|
||||
"bio": "Jeff was one of the earliest core team members on AngularJS.",
|
||||
"group": "Community"
|
||||
},
|
||||
"naomi": {
|
||||
"name": "Naomi Black",
|
||||
"picture": "naomi.jpg",
|
||||
"twitter": "naomitraveller",
|
||||
"website": "http://google.com/+NaomiBlack",
|
||||
"bio": "Naomi is Angular's TPM generalist and jack-of-all-trades.",
|
||||
"group": "Lead"
|
||||
}
|
||||
};
|
||||
}
|
@ -6,13 +6,14 @@ import 'rxjs/add/operator/map';
|
||||
import 'rxjs/add/operator/publishLast';
|
||||
|
||||
import { Logger } from 'app/shared/logger.service';
|
||||
import { Contributor } from './contributors.model';
|
||||
import { Contributor, ContributorGroup } from './contributors.model';
|
||||
|
||||
const contributorsPath = 'content/contributors.json';
|
||||
const knownGroups = ['Lead', 'Google', 'Community'];
|
||||
|
||||
@Injectable()
|
||||
export class ContributorService {
|
||||
contributors: Observable<Map<string, Contributor[]>>;
|
||||
contributors: Observable<ContributorGroup[]>;
|
||||
|
||||
constructor(private http: Http, private logger: Logger) {
|
||||
this.contributors = this.getContributors();
|
||||
@ -21,24 +22,49 @@ export class ContributorService {
|
||||
private getContributors() {
|
||||
const contributors = this.http.get(contributorsPath)
|
||||
.map(res => res.json())
|
||||
.map(contribs => {
|
||||
const contribGroups = new Map<string, Contributor[]>();
|
||||
|
||||
// Create group map
|
||||
.map(contribs => {
|
||||
const contribMap = new Map<string, Contributor[]>();
|
||||
Object.keys(contribs).forEach(key => {
|
||||
const contributor = contribs[key];
|
||||
const group = contributor.group;
|
||||
const contribGroup = contribGroups[group];
|
||||
const contribGroup = contribMap[group];
|
||||
if (contribGroup) {
|
||||
contribGroup.push(contributor);
|
||||
} else {
|
||||
contribGroups[group] = [contributor];
|
||||
contribMap[group] = [contributor];
|
||||
}
|
||||
});
|
||||
|
||||
return contribGroups;
|
||||
return contribMap;
|
||||
})
|
||||
|
||||
// Flatten group map into sorted group array of sorted contributors
|
||||
.map(cmap => {
|
||||
return Object.keys(cmap).map(key => {
|
||||
const order = knownGroups.indexOf(key);
|
||||
return {
|
||||
name: key,
|
||||
order: order === -1 ? knownGroups.length : order,
|
||||
contributors: cmap[key].sort(compareContributors)
|
||||
} as ContributorGroup;
|
||||
})
|
||||
.sort(compareGroups);
|
||||
})
|
||||
.publishLast();
|
||||
|
||||
contributors.connect();
|
||||
return contributors;
|
||||
}
|
||||
}
|
||||
|
||||
function compareContributors(l: Contributor, r: Contributor) {
|
||||
return l.name.toUpperCase() > r.name.toUpperCase() ? 1 : -1;
|
||||
}
|
||||
|
||||
function compareGroups(l: ContributorGroup, r: ContributorGroup) {
|
||||
return l.order === r.order ?
|
||||
(l.name > r.name ? 1 : -1) :
|
||||
l.order > r.order ? 1 : -1;
|
||||
}
|
||||
|
@ -1,3 +1,9 @@
|
||||
export class ContributorGroup {
|
||||
name: string;
|
||||
order: number;
|
||||
contributors: Contributor[];
|
||||
}
|
||||
|
||||
export class Contributor {
|
||||
group: string;
|
||||
name: string;
|
||||
|
@ -20,13 +20,15 @@ import { ContributorListComponent } from './contributor/contributor-list.compone
|
||||
import { ContributorComponent } from './contributor/contributor.component';
|
||||
import { DocTitleComponent } from './doc-title.component';
|
||||
import { LiveExampleComponent, EmbeddedPlunkerComponent } from './live-example/live-example.component';
|
||||
import { ResourceListComponent } from './resource/resource-list.component';
|
||||
import { ResourceService } from './resource/resource.service';
|
||||
|
||||
/** Components that can be embedded in docs
|
||||
* such as CodeExampleComponent, LiveExampleComponent,...
|
||||
*/
|
||||
export const embeddedComponents: any[] = [
|
||||
ApiListComponent, CodeExampleComponent, CodeTabsComponent,
|
||||
ContributorListComponent, DocTitleComponent, LiveExampleComponent
|
||||
ContributorListComponent, DocTitleComponent, LiveExampleComponent, ResourceListComponent
|
||||
];
|
||||
|
||||
/** Injectable class w/ property returning components that can be embedded in docs */
|
||||
@ -46,7 +48,8 @@ export class EmbeddedComponents {
|
||||
ContributorService,
|
||||
CopierService,
|
||||
EmbeddedComponents,
|
||||
PrettyPrinter
|
||||
PrettyPrinter,
|
||||
ResourceService
|
||||
],
|
||||
entryComponents: [ embeddedComponents ]
|
||||
})
|
||||
|
42
aio/src/app/embedded/resource/resource-list.component.html
Normal file
42
aio/src/app/embedded/resource/resource-list.component.html
Normal file
@ -0,0 +1,42 @@
|
||||
<div class="resources grid-fixed">
|
||||
<div class="c8">
|
||||
<div class="l-flex--column">
|
||||
<div class="showcase" *ngFor="let category of categories">
|
||||
<header class="c-resource-header">
|
||||
<a class="h-anchor-offset" id="{{category.id}}"></a>
|
||||
<h2 class="text-headline text-uppercase">{{category.title}}</h2>
|
||||
</header>
|
||||
|
||||
<div class="shadow-1">
|
||||
<div *ngFor="let subCategory of category.subCategories">
|
||||
<a class="h-anchor-offset" id="{{subCategory.id}}"></a>
|
||||
<h3 class="text-uppercase subcategory-title">{{subCategory.title}}</h3>
|
||||
|
||||
<div *ngFor="let resource of subCategory.resources">
|
||||
<div class="c-resource" *ngIf="resource.rev">
|
||||
<a class="l-flex--column resource-row-link" target="_blank" [href]="resource.url">
|
||||
<div>
|
||||
<h4>{{resource.title}}</h4>
|
||||
<p class="resource-description">{{resource.desc || 'No Description'}}</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="c3">
|
||||
<div class="c-resource-nav shadow-1 l-flex--column h-affix" [ngClass]="{ 'affix-top': scrollPos > 200 }">
|
||||
<div class="category" *ngFor="let category of categories">
|
||||
<a class="category-link h-capitalize" [href]="href(category)">{{category.title}}</a>
|
||||
<div class="subcategory" *ngFor="let subCategory of category.subCategories">
|
||||
<a class="subcategory-link" [href]="href(subCategory)">{{subCategory.title}}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
@ -0,0 +1,81 @@
|
||||
import { ReflectiveInjector } from '@angular/core';
|
||||
import { PlatformLocation } from '@angular/common';
|
||||
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { of } from 'rxjs/observable/of';
|
||||
|
||||
import { ResourceListComponent } from './resource-list.component';
|
||||
import { ResourceService } from './resource.service';
|
||||
|
||||
import { Category } from './resource.model';
|
||||
|
||||
// Testing the component class behaviors, independent of its template
|
||||
// Let e2e tests verify how it displays.
|
||||
describe('ResourceListComponent', () => {
|
||||
|
||||
let injector: ReflectiveInjector;
|
||||
let location: TestPlatformLocation;
|
||||
let resourceService: TestResourceService;
|
||||
|
||||
beforeEach(() => {
|
||||
injector = ReflectiveInjector.resolveAndCreate([
|
||||
ResourceListComponent,
|
||||
{provide: PlatformLocation, useClass: TestPlatformLocation },
|
||||
{provide: ResourceService, useClass: TestResourceService }
|
||||
]);
|
||||
|
||||
location = injector.get(PlatformLocation);
|
||||
resourceService = injector.get(ResourceService);
|
||||
});
|
||||
|
||||
it('should set the location w/o leading slashes', () => {
|
||||
location.pathname = '////resources';
|
||||
const component = getComponent();
|
||||
expect(component.location).toBe('resources');
|
||||
});
|
||||
|
||||
it('href(id) should return the expected href', () => {
|
||||
location.pathname = '////resources';
|
||||
const component = getComponent();
|
||||
expect(component.href({id: 'foo'})).toBe('resources#foo');
|
||||
});
|
||||
|
||||
it('should set scroll position to zero when no target element', () => {
|
||||
const component = getComponent();
|
||||
component.onScroll(undefined);
|
||||
expect(component.scrollPos).toBe(0);
|
||||
});
|
||||
|
||||
it('should set scroll position to element.scrollTop when that is defined', () => {
|
||||
const component = getComponent();
|
||||
component.onScroll({scrollTop: 42});
|
||||
expect(component.scrollPos).toBe(42);
|
||||
});
|
||||
|
||||
it('should set scroll position to element.body.scrollTop when that is defined', () => {
|
||||
const component = getComponent();
|
||||
component.onScroll({body: {scrollTop: 42}});
|
||||
expect(component.scrollPos).toBe(42);
|
||||
});
|
||||
|
||||
it('should set scroll position to 0 when no target.body.scrollTop defined', () => {
|
||||
const component = getComponent();
|
||||
component.onScroll({body: {}});
|
||||
expect(component.scrollPos).toBe(0);
|
||||
});
|
||||
|
||||
//// Test Helpers ////
|
||||
function getComponent(): ResourceListComponent { return injector.get(ResourceListComponent); }
|
||||
|
||||
class TestPlatformLocation {
|
||||
pathname = 'resources';
|
||||
}
|
||||
|
||||
class TestResourceService {
|
||||
categories = of(getTestData);
|
||||
}
|
||||
|
||||
function getTestData(): Category[] {
|
||||
return []; // Not interested in the data in these tests
|
||||
}
|
||||
});
|
37
aio/src/app/embedded/resource/resource-list.component.ts
Normal file
37
aio/src/app/embedded/resource/resource-list.component.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { Component, HostListener, OnInit } from '@angular/core';
|
||||
import { PlatformLocation } from '@angular/common';
|
||||
|
||||
import { Category } from './resource.model';
|
||||
import { ResourceService } from './resource.service';
|
||||
|
||||
@Component({
|
||||
selector: 'aio-resource-list',
|
||||
templateUrl: 'resource-list.component.html'
|
||||
})
|
||||
export class ResourceListComponent implements OnInit {
|
||||
|
||||
categories: Category[];
|
||||
location: string;
|
||||
scrollPos = 0;
|
||||
|
||||
constructor(
|
||||
location: PlatformLocation,
|
||||
private resourceService: ResourceService) {
|
||||
this.location = location.pathname.replace(/^\/+/, '');
|
||||
}
|
||||
|
||||
href(cat: {id: string}) {
|
||||
return this.location + '#' + cat.id;
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
// Not using async pipe because cats appear twice in template
|
||||
// No need to unsubscribe because categories observable completes.
|
||||
this.resourceService.categories.subscribe(cats => this.categories = cats);
|
||||
}
|
||||
|
||||
@HostListener('window:scroll', ['$event.target'])
|
||||
onScroll(target: any) {
|
||||
this.scrollPos = target ? target.scrollTop || target.body.scrollTop || 0 : 0;
|
||||
}
|
||||
}
|
23
aio/src/app/embedded/resource/resource.model.ts
Normal file
23
aio/src/app/embedded/resource/resource.model.ts
Normal file
@ -0,0 +1,23 @@
|
||||
export class Category {
|
||||
id: string; // "education"
|
||||
title: string; // "Education"
|
||||
order: number; // 2
|
||||
subCategories: SubCategory[];
|
||||
}
|
||||
|
||||
export class SubCategory {
|
||||
id: string; // "books"
|
||||
title: string; // "Books"
|
||||
order: number; // 1
|
||||
resources: Resource[];
|
||||
}
|
||||
|
||||
export class Resource {
|
||||
category: string; // "Education"
|
||||
subCategory: string; // "Books"
|
||||
id: string; // "-KLI8vJ0ZkvWhqPembZ7"
|
||||
desc: string; // "This books shows all the steps necessary for the development of SPA"
|
||||
rev: boolean; // true (always true in the original)
|
||||
title: string; // "Practical Angular 2",
|
||||
url: string; // "https://leanpub.com/practical-angular-2"
|
||||
}
|
161
aio/src/app/embedded/resource/resource.service.spec.ts
Normal file
161
aio/src/app/embedded/resource/resource.service.spec.ts
Normal file
@ -0,0 +1,161 @@
|
||||
import { ReflectiveInjector } from '@angular/core';
|
||||
import { Http, ConnectionBackend, RequestOptions, BaseRequestOptions, Response, ResponseOptions } from '@angular/http';
|
||||
import { MockBackend } from '@angular/http/testing';
|
||||
|
||||
import { ResourceService } from './resource.service';
|
||||
import { Category, SubCategory, Resource } from './resource.model';
|
||||
import { Logger } from 'app/shared/logger.service';
|
||||
|
||||
describe('ResourceService', () => {
|
||||
|
||||
let injector: ReflectiveInjector;
|
||||
let backend: MockBackend;
|
||||
let resourceService: ResourceService;
|
||||
|
||||
function createResponse(body: any) {
|
||||
return new Response(new ResponseOptions({ body: JSON.stringify(body) }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
injector = ReflectiveInjector.resolveAndCreate([
|
||||
ResourceService,
|
||||
{ provide: ConnectionBackend, useClass: MockBackend },
|
||||
{ provide: RequestOptions, useClass: BaseRequestOptions },
|
||||
Http,
|
||||
Logger
|
||||
]);
|
||||
|
||||
backend = injector.get(ConnectionBackend);
|
||||
resourceService = injector.get(ResourceService);
|
||||
});
|
||||
|
||||
it('should be creatable', () => {
|
||||
expect(resourceService).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should make a single connection to the server', () => {
|
||||
expect(backend.connectionsArray.length).toEqual(1);
|
||||
expect(backend.connectionsArray[0].request.url).toEqual('content/resources.json');
|
||||
});
|
||||
|
||||
describe('#categories', () => {
|
||||
|
||||
let categories: Category[];
|
||||
let testData: any;
|
||||
|
||||
beforeEach(() => {
|
||||
testData = getTestResources();
|
||||
backend.connectionsArray[0].mockRespond(createResponse(testData));
|
||||
resourceService.categories.subscribe(results => categories = results);
|
||||
});
|
||||
|
||||
it('categories observable should complete', () => {
|
||||
let completed = false;
|
||||
resourceService.categories.subscribe(null, null, () => completed = true);
|
||||
expect(true).toBe(true, 'observable completed');
|
||||
});
|
||||
|
||||
it('should reshape contributors.json to sorted category array', () => {
|
||||
const actualIds = categories.map(c => c.id).join(',');
|
||||
expect(actualIds).toBe('cat-1,cat-3');
|
||||
});
|
||||
|
||||
it('should convert ids to canonical form', () => {
|
||||
// canonical form is lowercase with dashes for spaces
|
||||
const cat = categories[1];
|
||||
const sub = cat.subCategories[0];
|
||||
const res = sub.resources[0];
|
||||
|
||||
expect(cat.id).toBe('cat-3', 'category id');
|
||||
expect(sub.id).toBe('cat3-subcat2', 'subcat id');
|
||||
expect(res.id).toBe('cat3-subcat2-res1', 'resources id');
|
||||
});
|
||||
|
||||
it('resource knows its category and sub-category titles', () => {
|
||||
const cat = categories[1];
|
||||
const sub = cat.subCategories[0];
|
||||
const res = sub.resources[0];
|
||||
expect(res.category).toBe(cat.title, 'category title');
|
||||
expect(res.subCategory).toBe(sub.title, 'subcategory title');
|
||||
});
|
||||
|
||||
it('should have expected SubCategories of "Cat 3"', () => {
|
||||
const actualIds = categories[1].subCategories.map(s => s.id).join(',');
|
||||
expect(actualIds).toBe('cat3-subcat2,cat3-subcat1');
|
||||
});
|
||||
|
||||
it('should have expected sorted resources of "Cat 1:SubCat1"', () => {
|
||||
const actualIds = categories[0].subCategories[0].resources.map(r => r.id).join(',');
|
||||
expect(actualIds).toBe('a-a-a,s-s-s,z-z-z');
|
||||
});
|
||||
});
|
||||
|
||||
it('should do WHAT(?) if the request fails');
|
||||
});
|
||||
|
||||
function getTestResources() {
|
||||
// tslint:disable:quotemark
|
||||
return {
|
||||
"Cat 3": {
|
||||
"order": 3,
|
||||
"subCategories": {
|
||||
"Cat3 SubCat1": {
|
||||
"order": 2,
|
||||
"resources": {
|
||||
"Cat3 SubCat1 Res1": {
|
||||
"desc": "Meetup in Barcelona, Spain. ",
|
||||
"rev": true,
|
||||
"title": "Angular Beers",
|
||||
"url": "http://www.meetup.com/AngularJS-Beers/"
|
||||
},
|
||||
"Cat3 SubCat1 Res2": {
|
||||
"desc": "Angular Camps in Barcelona, Spain.",
|
||||
"rev": true,
|
||||
"title": "Angular Camp",
|
||||
"url": "http://angularcamp.org/"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Cat3 SubCat2": {
|
||||
"order": 1,
|
||||
"resources": {
|
||||
"Cat3 SubCat2 Res1": {
|
||||
"desc": "A community index of components and libraries",
|
||||
"rev": true,
|
||||
"title": "Catalog of Angular Components & Libraries",
|
||||
"url": "https://a/b/c"
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"Cat 1": {
|
||||
"order": 1,
|
||||
"subCategories": {
|
||||
"Cat1 SubCat1": {
|
||||
"order": 1,
|
||||
"resources": {
|
||||
"S S S": {
|
||||
"desc": "SSS",
|
||||
"rev": true,
|
||||
"title": "Sssss",
|
||||
"url": "http://s/s/s"
|
||||
},
|
||||
"A A A": {
|
||||
"desc": "AAA",
|
||||
"rev": true,
|
||||
"title": "Aaaa",
|
||||
"url": "http://a/a/a"
|
||||
},
|
||||
"Z Z Z": {
|
||||
"desc": "ZZZ",
|
||||
"rev": true,
|
||||
"title": "Zzzzz",
|
||||
"url": "http://z/z/z"
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
83
aio/src/app/embedded/resource/resource.service.ts
Normal file
83
aio/src/app/embedded/resource/resource.service.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Http } from '@angular/http';
|
||||
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import 'rxjs/add/operator/map';
|
||||
import 'rxjs/add/operator/publishLast';
|
||||
|
||||
import { Logger } from 'app/shared/logger.service';
|
||||
import { Category, Resource, SubCategory } from './resource.model';
|
||||
|
||||
const resourcesPath = 'content/resources.json';
|
||||
|
||||
@Injectable()
|
||||
export class ResourceService {
|
||||
categories: Observable<Category[]>;
|
||||
|
||||
constructor(private http: Http, private logger: Logger) {
|
||||
this.categories = this.getCategories();
|
||||
}
|
||||
|
||||
private getCategories(): Observable<Category[]> {
|
||||
|
||||
const categories = this.http.get(resourcesPath)
|
||||
.map(res => res.json())
|
||||
.map(data => mkCategories(data))
|
||||
.publishLast();
|
||||
|
||||
categories.connect();
|
||||
return categories;
|
||||
};
|
||||
}
|
||||
|
||||
// Extract sorted Category[] from resource JSON data
|
||||
function mkCategories(categoryJson: any): Category[] {
|
||||
return Object.keys(categoryJson).map(catKey => {
|
||||
const cat = categoryJson[catKey];
|
||||
return {
|
||||
id: makeId(catKey),
|
||||
title: catKey,
|
||||
order: cat.order,
|
||||
subCategories: mkSubCategories(cat.subCategories, catKey)
|
||||
} as Category;
|
||||
})
|
||||
.sort(compareCats);
|
||||
}
|
||||
|
||||
// Extract sorted SubCategory[] from JSON category data
|
||||
function mkSubCategories(subCategoryJson: any, catKey: string): SubCategory[] {
|
||||
return Object.keys(subCategoryJson).map(subKey => {
|
||||
const sub = subCategoryJson[subKey];
|
||||
return {
|
||||
id: makeId(subKey),
|
||||
title: subKey,
|
||||
order: sub.order,
|
||||
resources: mkResources(sub.resources, subKey, catKey)
|
||||
} as SubCategory;
|
||||
})
|
||||
.sort(compareCats);
|
||||
}
|
||||
|
||||
// Extract sorted Resource[] from JSON subcategory data
|
||||
function mkResources(resourceJson: any, subKey: string, catKey: string): Resource[] {
|
||||
return Object.keys(resourceJson).map(resKey => {
|
||||
const res = resourceJson[resKey];
|
||||
res.category = catKey;
|
||||
res.subCategory = subKey;
|
||||
res.id = makeId(resKey);
|
||||
return res as Resource;
|
||||
})
|
||||
.sort(compareTitles);
|
||||
}
|
||||
|
||||
function compareCats(l: Category | SubCategory, r: Category | SubCategory) {
|
||||
return l.order === r.order ? compareTitles(l, r) : l.order > r.order ? 1 : -1;
|
||||
}
|
||||
|
||||
function compareTitles(l: {title: string}, r: {title: string}) {
|
||||
return l.title.toUpperCase() > r.title.toUpperCase() ? 1 : -1;
|
||||
}
|
||||
|
||||
function makeId(title: string) {
|
||||
return title.toLowerCase().replace(/\s+/g, '-');
|
||||
}
|
@ -51,7 +51,12 @@ describe('NavigationService', () => {
|
||||
expect(viewsEvents).toEqual([]);
|
||||
backend.connectionsArray[0].mockRespond(createResponse({ TopBar: [ { url: 'a' }] }));
|
||||
expect(viewsEvents).toEqual([{ TopBar: [ { url: 'a' }] }]);
|
||||
});
|
||||
|
||||
it('navigationViews observable should complete', () => {
|
||||
let completed = false;
|
||||
navService.navigationViews.subscribe(null, null, () => completed = true);
|
||||
expect(true).toBe(true, 'observable completed');
|
||||
});
|
||||
|
||||
it('should return the same object to all subscribers', () => {
|
||||
|
@ -354,7 +354,7 @@ describe('LocationService', () => {
|
||||
});
|
||||
|
||||
it('should call locationChanged with initial URL', () => {
|
||||
const initialUrl = location.path().replace(/^\/+/, '');
|
||||
const initialUrl = location.path().replace(/^\/+/, ''); // strip leading slashes
|
||||
|
||||
expect(gaLocationChanged.calls.count()).toBe(1, 'gaService.locationChanged');
|
||||
const args = gaLocationChanged.calls.first().args;
|
||||
|
@ -125,4 +125,8 @@ header.bckground-sky.l-relative {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.text-uppercase {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
@ -21,4 +21,5 @@
|
||||
@import 'hr';
|
||||
@import 'live-example';
|
||||
@import 'scrollbar';
|
||||
@import 'callout';
|
||||
@import 'callout';
|
||||
@import 'resources';
|
238
aio/src/styles/2-modules/_resources.scss
Normal file
238
aio/src/styles/2-modules/_resources.scss
Normal file
@ -0,0 +1,238 @@
|
||||
.text-headline {
|
||||
margin: 0px 0px ($unit * 2) 0px;
|
||||
font-size: 24px;
|
||||
font-weight: 400;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.grid-fixed {
|
||||
margin: 0 auto;
|
||||
*zoom: 1;
|
||||
width: 960px;
|
||||
}
|
||||
|
||||
.grid-fixed .c3, .grid-fixed .c8, {
|
||||
display: inline;
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.grid-fixed:after, .grid-fixed:before {
|
||||
content: '.';
|
||||
clear: both;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
visibility: hidden;
|
||||
font-size: 0;
|
||||
line-height: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
.grid-fixed .c3 {
|
||||
width: 220px;
|
||||
}
|
||||
.grid-fixed .c8 {
|
||||
width: 620px;
|
||||
}
|
||||
|
||||
@media handheld and (max-width: 480px), screen and (max-width: 480px), screen and (max-width: 900px) {
|
||||
.grid-fixed {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media handheld and (max-width: 480px), screen and (max-width: 480px), screen and (max-width: 900px) {
|
||||
.grid-fixed .c3, .grid-fixed .c8 {
|
||||
margin-left: 20px;
|
||||
margin-right: 20px;
|
||||
float: none;
|
||||
display: block;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media handheld and (max-width: 480px), screen and (max-width: 480px), screen and (max-width: 480px) {
|
||||
.grid-fixed .c3, .grid-fixed .c8 {
|
||||
margin-left: 0px;
|
||||
margin-right: 0px;
|
||||
float: none;
|
||||
display: block;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media handheld and (max-width: 900px), screen and (max-width: 900px) {
|
||||
/* line 6, ../scss/_responsive.scss */
|
||||
.grid-fixed{
|
||||
margin: 0 auto;
|
||||
*zoom: 1;
|
||||
}
|
||||
.grid-fixed:after, .grid-fixed:before, {
|
||||
content: '.';
|
||||
clear: both;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
visibility: hidden;
|
||||
font-size: 0;
|
||||
line-height: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media handheld and (max-width: 480px), screen and (max-width: 480px) {
|
||||
/* line 6, ../scss/_responsive.scss */
|
||||
.grid-fixed {
|
||||
margin: 0 auto;
|
||||
*zoom: 1;
|
||||
}
|
||||
.grid-fixed:after, .grid-fixed:before {
|
||||
content: '.';
|
||||
clear: both;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
visibility: hidden;
|
||||
font-size: 0;
|
||||
line-height: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.resources {
|
||||
|
||||
.shadow-1 {
|
||||
transition: box-shadow 0.28s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 1px 4px 0 rgba($black, 0.37);
|
||||
}
|
||||
|
||||
.showcase {
|
||||
margin-bottom: $unit * 6;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.h-affix {
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
.affix-top {
|
||||
top: 150px;
|
||||
}
|
||||
|
||||
.c-resource {
|
||||
h4 {
|
||||
margin: 0;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.c-resource-nav {
|
||||
margin-top: 48px;
|
||||
width: $unit * 20;
|
||||
z-index: 1;
|
||||
background-color: #fff;
|
||||
border-radius: 2px;
|
||||
|
||||
a {
|
||||
color: #373E41;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.category {
|
||||
padding: 10px 0;
|
||||
|
||||
.category-link {
|
||||
display: block;
|
||||
margin: 2px 0;
|
||||
padding: 3px 14px;
|
||||
font-size: 18px !important;
|
||||
|
||||
&:hover {
|
||||
background: #edf0f2;
|
||||
color: #2B85E7;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.subcategory {
|
||||
.subcategory-link {
|
||||
display: block;
|
||||
margin: 2px 0;
|
||||
padding: 4px 14px;
|
||||
|
||||
&:hover {
|
||||
background: #edf0f2;
|
||||
color: #2B85E7;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.h-anchor-offset {
|
||||
display: block;
|
||||
position: relative;
|
||||
top: -20px;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.l-flex--column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.c-resource-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.c-contribute {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.c-resource-header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.subcategory-title {
|
||||
padding: 16px 23px;
|
||||
margin: 0;
|
||||
background-color: $mist;
|
||||
color: #373E41;
|
||||
}
|
||||
|
||||
.h-capitalize {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.h-hide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.resource-row-link {
|
||||
color: #1a2326;
|
||||
border: transparent solid 1px;
|
||||
margin: 0;
|
||||
padding: 16px 23px 16px 23px;
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
transition: all .3s;
|
||||
}
|
||||
|
||||
.resource-row-link:hover {
|
||||
color: #1a2326;
|
||||
text-decoration: none;
|
||||
border-color: #2B85E7;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 8px 8px rgba(1, 67, 163, .24), 0 0 8px rgba(1, 67, 163, .12), 0 6px 18px rgba(43, 133, 231, .12);
|
||||
transform: translate3d(0, -2px, 0);
|
||||
}
|
||||
|
||||
@media(max-width: 900px) {
|
||||
.c-resource-nav {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
@ -14,6 +14,7 @@ $white: #FFFFFF;
|
||||
$offwhite: #FAFAFA;
|
||||
$backgroundgray: #F1F1F1;
|
||||
$lightgray: #DBDBDB;
|
||||
$mist: #ECEFF1;
|
||||
$mediumgray: #7E7E7E;
|
||||
$darkgray: #333;
|
||||
$black: #0A1014;
|
||||
|
Reference in New Issue
Block a user