Skip to content
This repository was archived by the owner on Feb 26, 2024. It is now read-only.

Commit a09db7a

Browse files
committed
feat(issues): add basic issue list implementation and github service
Closes #41
1 parent d202eb6 commit a09db7a

File tree

15 files changed

+426
-38
lines changed

15 files changed

+426
-38
lines changed

karma-test-shim.js

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,61 @@ __karma__.loaded = function () {
77
};
88

99
System.config({
10+
map: {
11+
angularfire2: 'base/dist/vendor/angularfire2',
12+
firebase: 'base/dist/vendor/firebase/lib',
13+
'@angular2-material': 'base/dist/vendor/@angular2-material'
14+
},
1015
packages: {
16+
app: {
17+
format: 'register',
18+
defaultExtension: 'js'
19+
},
20+
angularfire2: {
21+
defaultExtension: 'js',
22+
format: 'cjs',
23+
main: 'angularfire2.js'
24+
},
25+
firebase: {
26+
defaultExtension: 'js',
27+
format: 'cjs',
28+
main: 'firebase-web.js'
29+
},
30+
'@angular2-material/core': {
31+
format: 'cjs',
32+
defaultExtension: 'js',
33+
main: 'core.js'
34+
},
35+
'@angular2-material/toolbar': {
36+
format: 'cjs',
37+
defaultExtension: 'js',
38+
main: 'toolbar.js'
39+
},
40+
'@angular2-material/sidenav': {
41+
format: 'cjs',
42+
defaultExtension: 'js',
43+
main: 'sidenav.js'
44+
},
45+
'@angular2-material/button': {
46+
format: 'cjs',
47+
defaultExtension: 'js',
48+
main: 'button.js'
49+
},
50+
'@angular2-material/card': {
51+
format: 'cjs',
52+
defaultExtension: 'js',
53+
main: 'card.js'
54+
},
55+
'@angular2-material/progress-circle': {
56+
format: 'cjs',
57+
defaultExtension: 'js',
58+
main: 'progress-circle.js'
59+
},
60+
'@angular2-material/list': {
61+
format: 'cjs',
62+
defaultExtension: 'js',
63+
main: 'list.js'
64+
},
1165
'base/dist/app': {
1266
defaultExtension: false,
1367
format: 'register',

karma.conf.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ module.exports = function (config) {
2424
{ pattern: 'node_modules/angular2/bundles/router.dev.js', included: true, watched: true },
2525
{ pattern: 'node_modules/angular2/bundles/testing.dev.js', included: true, watched: true },
2626

27-
2827
{ pattern: 'karma-test-shim.js', included: true, watched: true },
2928

3029
// paths loaded via module imports

package.json

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,23 +13,24 @@
1313
},
1414
"private": true,
1515
"dependencies": {
16-
"angular2": "2.0.0-beta.14",
17-
"es6-shim": "^0.35.0",
18-
"reflect-metadata": "0.1.2",
19-
"rxjs": "5.0.0-beta.2",
20-
"systemjs": "0.19.20",
21-
"zone.js": "^0.6.4",
2216
"@angular2-material/button": "^2.0.0-alpha.2",
2317
"@angular2-material/card": "^2.0.0-alpha.2",
2418
"@angular2-material/core": "^2.0.0-alpha.2",
19+
"@angular2-material/list": "^2.0.0-alpha.2",
2520
"@angular2-material/progress-circle": "^2.0.0-alpha.2",
2621
"@angular2-material/sidenav": "^2.0.0-alpha.2",
2722
"@angular2-material/toolbar": "^2.0.0-alpha.2",
2823
"@ngrx/store": "^1.3.3",
29-
"angularfire2": "^2.0.0-alpha.14",
24+
"angular2": "2.0.0-beta.14",
25+
"angularfire2": "^2.0.0-alpha.16",
3026
"es6-promise": "^3.1.2",
27+
"es6-shim": "^0.35.0",
3128
"firebase": "^2.4.2",
32-
"material-design-icons": "^2.2.3"
29+
"material-design-icons": "^2.2.3",
30+
"reflect-metadata": "0.1.2",
31+
"rxjs": "5.0.0-beta.2",
32+
"systemjs": "0.19.20",
33+
"zone.js": "^0.6.4"
3334
},
3435
"devDependencies": {
3536
"angular-cli": "0.0.*",

src/client/app.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@ import 'rxjs/add/operator/concat';
1313
import 'rxjs/add/operator/switchMap';
1414
import 'rxjs/add/operator/map';
1515
import 'rxjs/add/operator/distinctUntilChanged';
16+
import 'rxjs/add/operator/combineLatest';
17+
import 'rxjs/add/operator/catch';
1618

1719
import {IssueCliApp} from './app/issue-cli';
18-
import {FB_URL, IS_PRERENDER, IS_POST_LOGIN} from './app/config';
20+
import {FB_URL, IS_PRERENDER, IS_POST_LOGIN, LOCAL_STORAGE} from './app/config';
1921

2022
// Checks if this is the OAuth redirect callback from Firebase
2123
// Has to be global so can be used in CanActivate
@@ -27,6 +29,9 @@ bootstrap(IssueCliApp, [
2729
provide(IS_POST_LOGIN, {
2830
useValue: (<any>window).__IS_POST_LOGIN
2931
}),
32+
provide(LOCAL_STORAGE, {
33+
useValue: (<any>window.localStorage)
34+
}),
3035
firebaseAuthConfig(
3136
{provider: AuthProviders.Github, method: AuthMethods.Redirect, scope: ['repo']}),
3237
HTTP_PROVIDERS

src/client/app/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ import {OpaqueToken} from 'angular2/core';
22
export const FB_URL = 'https://issue-zero.firebaseIO.com';
33
export const IS_PRERENDER = new OpaqueToken('IsPrerender');
44
export const IS_POST_LOGIN = new OpaqueToken('IsPostLogin');
5+
export const LOCAL_STORAGE = new OpaqueToken('LocalStorage');

src/client/app/github/github.spec.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
declare var jasmine:any;
2+
declare var expect:any;
3+
import {
4+
it,
5+
iit,
6+
describe,
7+
ddescribe,
8+
// expect,
9+
inject,
10+
injectAsync,
11+
TestComponentBuilder,
12+
beforeEach,
13+
beforeEachProviders
14+
} from 'angular2/testing';
15+
import {provide} from 'angular2/core';
16+
import {AngularFire, FIREBASE_PROVIDERS, defaultFirebase} from 'angularfire2';
17+
import {Github} from './github';
18+
import {HTTP_PROVIDERS, XHRBackend, Response, BaseResponseOptions} from 'angular2/http';
19+
import {MockBackend, MockConnection} from 'angular2/http/testing';
20+
import {LOCAL_STORAGE} from '../config';
21+
import {ScalarObservable} from 'rxjs/observable/ScalarObservable';
22+
23+
describe('Github Service', () => {
24+
25+
beforeEachProviders(() => [
26+
Github,
27+
FIREBASE_PROVIDERS,
28+
defaultFirebase('https://issue-zero.firebaseio.com'),
29+
HTTP_PROVIDERS,
30+
provide(XHRBackend, {
31+
useClass: MockBackend
32+
}),
33+
provide(LOCAL_STORAGE, {
34+
useClass: MockLocalStorage
35+
})]);
36+
37+
beforeEach(inject([AngularFire], (angularFire) => {
38+
angularFire.auth = new ScalarObservable({github: {
39+
accessToken: 'fooAccessToken'
40+
}})
41+
}));
42+
43+
44+
it('should make a request to the Github API', inject([Github, XHRBackend], (service: Github, backend: MockBackend) => {
45+
var nextSpy = jasmine.createSpy('next')
46+
backend.connections.subscribe(nextSpy);
47+
48+
var fetchObservable = service.fetch('/repo', 'bar=baz');
49+
expect(nextSpy).not.toHaveBeenCalled();
50+
fetchObservable.subscribe();
51+
expect(nextSpy).toHaveBeenCalled();
52+
}));
53+
54+
55+
it('should not make a request to the Github API if value exists in cache', inject(
56+
[Github, XHRBackend, LOCAL_STORAGE],
57+
(service: Github, backend: MockBackend, localStorage) => {
58+
var connectionCreated = jasmine.createSpy('connectionCreated');
59+
var valueReceived = jasmine.createSpy('valueReceived');
60+
backend.connections.subscribe(connectionCreated);
61+
62+
localStorage.setItem('izCache/repo', '{"issues": ["1"]}');
63+
64+
var fetchObservable = service.fetch('/repo', 'bar=baz');
65+
expect(connectionCreated).not.toHaveBeenCalled();
66+
fetchObservable.subscribe(valueReceived);
67+
expect(connectionCreated).not.toHaveBeenCalled();
68+
expect(valueReceived.calls.argsFor(0)[0]).toEqual({issues: ['1']})
69+
}));
70+
71+
it('should set http response json to cache', inject(
72+
[Github, XHRBackend, LOCAL_STORAGE],
73+
(service: Github, backend: MockBackend, localStorage) => {
74+
var setItemSpy = spyOn(localStorage, 'setItem');
75+
backend.connections.subscribe((c:MockConnection) => {
76+
c.mockRespond(new Response(new BaseResponseOptions().merge({body: `{"issues": ["1","2","3"]}`})));
77+
});
78+
79+
var fetchObservable = service.fetch('/repo', 'bar=baz');
80+
fetchObservable.subscribe();
81+
expect(setItemSpy).toHaveBeenCalledWith('izCache/repo', '{"issues": ["1","2","3"]}');
82+
}));
83+
});
84+
85+
class MockLocalStorage {
86+
private _cache = {};
87+
getItem (key:string): string {
88+
return key in this._cache ? this._cache[key] : null;
89+
}
90+
91+
setItem (key:string, value:string): void {
92+
this._cache[key] = value;
93+
}
94+
}

src/client/app/github/github.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import {AngularFire} from 'angularfire2';
2+
import {Inject, Injectable} from 'angular2/core';
3+
import {Http} from 'angular2/http';
4+
import {Observable} from 'rxjs/Observable';
5+
import {ScalarObservable} from 'rxjs/observable/ScalarObservable';
6+
import {ErrorObservable} from 'rxjs/observable/ErrorObservable';
7+
import {_catch} from 'rxjs/operator/catch';
8+
import {map} from 'rxjs/operator/map';
9+
import {_do} from 'rxjs/operator/do';
10+
import {mergeMap} from 'rxjs/operator/mergeMap';
11+
12+
import {User, Repo} from './types';
13+
import {LOCAL_STORAGE} from '../config';
14+
15+
const GITHUB_API = 'https://api.github.com';
16+
17+
interface LocalStorage {
18+
getItem(key:string): string;
19+
setItem(key:string, value:string): void;
20+
}
21+
22+
@Injectable()
23+
export class Github {
24+
25+
constructor(
26+
private _http:Http,
27+
@Inject(LOCAL_STORAGE) private _localStorage:LocalStorage,
28+
private _af:AngularFire) {}
29+
30+
fetch(path:string, params?: string): Observable<Repo[]> {
31+
var accessToken = map.call(this._af.auth, (auth:FirebaseAuthData) => auth.github.accessToken);
32+
var httpReq = mergeMap.call(accessToken, (tokenValue) => this._httpRequest(path, tokenValue, params));
33+
return _catch.call(this._getCache(path), () => httpReq);
34+
}
35+
36+
_httpRequest (path:string, accessToken:string, params?:string) {
37+
var url = `${GITHUB_API}${path}?${params ? params + '&' : ''}access_token=${accessToken}`
38+
var reqObservable = this._http.get(url);
39+
// Set the http response to cache
40+
// TODO(jeffbcross): issues should be cached in more structured and queryable format
41+
var setCacheSideEffect = _do.call(reqObservable, res => this._setCache(path, res.text()));
42+
// Get the JSON object from the response
43+
return map.call(setCacheSideEffect, res => res.json());
44+
}
45+
46+
/**
47+
* TODO(jeffbcross): get rid of this for a more sophisticated, queryable cache
48+
*/
49+
_getCache (path:string): Observable<Repo> {
50+
var cacheKey = `izCache${path}`;
51+
var cache = this._localStorage.getItem(cacheKey);
52+
if (cache) {
53+
return new ScalarObservable(JSON.parse(cache));
54+
} else {
55+
return ErrorObservable.create(null);
56+
}
57+
}
58+
59+
/**
60+
* TODO(jeffbcross): get rid of this for a more sophisticated, queryable cache
61+
*/
62+
_setCache(path:string, value:string): void {
63+
var cacheKey = `izCache${path}`;
64+
this._localStorage.setItem(cacheKey, value);
65+
}
66+
}

src/client/app/github/types.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
export type Repo = {
2+
full_name: string;
3+
owner: User;
4+
}
5+
6+
export type User = {
7+
avatar_url: string;
8+
login: string;
9+
}
10+
11+
export enum GithubObjects {
12+
User,
13+
Repo,
14+
Issue
15+
}

src/client/app/issue-cli.spec.ts

Lines changed: 0 additions & 21 deletions
This file was deleted.

src/client/app/issues/issues.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ import {List} from './list/list';
44

55
@Component({
66
providers: [],
7-
template: `Issues route`
7+
template: `<router-outlet></router-outlet>`,
8+
directives: [RouterOutlet]
89
})
910
@RouteConfig([
10-
{path: '/list/...', name: 'List', component: List, useAsDefault: true},
11+
{path: '/list', name: 'List', component: List, useAsDefault: true},
1112
])
1213
export class Issues {
1314
constructor() {}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import {Component, EventEmitter, Input, Output} from 'angular2/core';
2+
import {MD_LIST_DIRECTIVES} from '@angular2-material/list';
3+
4+
// truncate.ts
5+
import {Pipe} from 'angular2/core'
6+
7+
@Pipe({
8+
name: 'truncate'
9+
})
10+
export class TruncatePipe {
11+
transform(value: string, args: string[]) : string {
12+
let limit = args.length > 0 ? parseInt(args[0], 10) : 10;
13+
let trail = args.length > 1 ? args[1] : '...';
14+
15+
return value.length > limit ? value.substring(0, limit) + trail : value;
16+
}
17+
}
18+
19+
20+
@Component({
21+
selector: 'issue-row',
22+
template: `
23+
<md-list-item>
24+
<img md-list-avatar [src]="issue.user.avatar_url + '&s=40'" alt="{{issue.user.login}} logo">
25+
<span md-line> {{issue.title}} </span>
26+
<p md-line>
27+
@{{issue.user.login}} -- {{issue.body | truncate: 140 : '...' }}
28+
</p>
29+
<md-list-item>
30+
`,
31+
providers: [],
32+
directives: [MD_LIST_DIRECTIVES],
33+
pipes: [TruncatePipe]
34+
})
35+
export class IssueRow {
36+
@Input('issue') issue:any;
37+
38+
constructor() {}
39+
40+
}

0 commit comments

Comments
 (0)