diff --git a/karma-test-shim.js b/karma-test-shim.js index 22254c1..cb817d6 100644 --- a/karma-test-shim.js +++ b/karma-test-shim.js @@ -7,7 +7,61 @@ __karma__.loaded = function () { }; System.config({ + map: { + angularfire2: 'base/dist/vendor/angularfire2', + firebase: 'base/dist/vendor/firebase/lib', + '@angular2-material': 'base/dist/vendor/@angular2-material' + }, packages: { + app: { + format: 'register', + defaultExtension: 'js' + }, + angularfire2: { + defaultExtension: 'js', + format: 'cjs', + main: 'angularfire2.js' + }, + firebase: { + defaultExtension: 'js', + format: 'cjs', + main: 'firebase-web.js' + }, + '@angular2-material/core': { + format: 'cjs', + defaultExtension: 'js', + main: 'core.js' + }, + '@angular2-material/toolbar': { + format: 'cjs', + defaultExtension: 'js', + main: 'toolbar.js' + }, + '@angular2-material/sidenav': { + format: 'cjs', + defaultExtension: 'js', + main: 'sidenav.js' + }, + '@angular2-material/button': { + format: 'cjs', + defaultExtension: 'js', + main: 'button.js' + }, + '@angular2-material/card': { + format: 'cjs', + defaultExtension: 'js', + main: 'card.js' + }, + '@angular2-material/progress-circle': { + format: 'cjs', + defaultExtension: 'js', + main: 'progress-circle.js' + }, + '@angular2-material/list': { + format: 'cjs', + defaultExtension: 'js', + main: 'list.js' + }, 'base/dist/app': { defaultExtension: false, format: 'register', diff --git a/karma.conf.js b/karma.conf.js index 4b352a9..5dfc00a 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -24,7 +24,6 @@ module.exports = function (config) { { pattern: 'node_modules/angular2/bundles/router.dev.js', included: true, watched: true }, { pattern: 'node_modules/angular2/bundles/testing.dev.js', included: true, watched: true }, - { pattern: 'karma-test-shim.js', included: true, watched: true }, // paths loaded via module imports diff --git a/package.json b/package.json index b14bb29..a324d48 100644 --- a/package.json +++ b/package.json @@ -13,23 +13,24 @@ }, "private": true, "dependencies": { - "angular2": "2.0.0-beta.14", - "es6-shim": "^0.35.0", - "reflect-metadata": "0.1.2", - "rxjs": "5.0.0-beta.2", - "systemjs": "0.19.20", - "zone.js": "^0.6.4", "@angular2-material/button": "^2.0.0-alpha.2", "@angular2-material/card": "^2.0.0-alpha.2", "@angular2-material/core": "^2.0.0-alpha.2", + "@angular2-material/list": "^2.0.0-alpha.2", "@angular2-material/progress-circle": "^2.0.0-alpha.2", "@angular2-material/sidenav": "^2.0.0-alpha.2", "@angular2-material/toolbar": "^2.0.0-alpha.2", "@ngrx/store": "^1.3.3", - "angularfire2": "^2.0.0-alpha.14", + "angular2": "2.0.0-beta.14", + "angularfire2": "^2.0.0-alpha.16", "es6-promise": "^3.1.2", + "es6-shim": "^0.35.0", "firebase": "^2.4.2", - "material-design-icons": "^2.2.3" + "material-design-icons": "^2.2.3", + "reflect-metadata": "0.1.2", + "rxjs": "5.0.0-beta.2", + "systemjs": "0.19.20", + "zone.js": "^0.6.4" }, "devDependencies": { "angular-cli": "0.0.*", diff --git a/src/client/app.ts b/src/client/app.ts index f3bd388..bde6305 100644 --- a/src/client/app.ts +++ b/src/client/app.ts @@ -13,9 +13,11 @@ import 'rxjs/add/operator/concat'; import 'rxjs/add/operator/switchMap'; import 'rxjs/add/operator/map'; import 'rxjs/add/operator/distinctUntilChanged'; +import 'rxjs/add/operator/combineLatest'; +import 'rxjs/add/operator/catch'; import {IssueCliApp} from './app/issue-cli'; -import {FB_URL, IS_PRERENDER, IS_POST_LOGIN} from './app/config'; +import {FB_URL, IS_PRERENDER, IS_POST_LOGIN, LOCAL_STORAGE} from './app/config'; // Checks if this is the OAuth redirect callback from Firebase // Has to be global so can be used in CanActivate @@ -27,6 +29,9 @@ bootstrap(IssueCliApp, [ provide(IS_POST_LOGIN, { useValue: (window).__IS_POST_LOGIN }), + provide(LOCAL_STORAGE, { + useValue: (window.localStorage) + }), firebaseAuthConfig( {provider: AuthProviders.Github, method: AuthMethods.Redirect, scope: ['repo']}), HTTP_PROVIDERS diff --git a/src/client/app/config.ts b/src/client/app/config.ts index 9d7c4ab..b8ec68d 100644 --- a/src/client/app/config.ts +++ b/src/client/app/config.ts @@ -2,3 +2,4 @@ import {OpaqueToken} from 'angular2/core'; export const FB_URL = 'https://issue-zero.firebaseIO.com'; export const IS_PRERENDER = new OpaqueToken('IsPrerender'); export const IS_POST_LOGIN = new OpaqueToken('IsPostLogin'); +export const LOCAL_STORAGE = new OpaqueToken('LocalStorage'); diff --git a/src/client/app/github/github.spec.ts b/src/client/app/github/github.spec.ts new file mode 100644 index 0000000..0018361 --- /dev/null +++ b/src/client/app/github/github.spec.ts @@ -0,0 +1,94 @@ +declare var jasmine:any; +declare var expect:any; +import { + it, + iit, + describe, + ddescribe, + // expect, + inject, + injectAsync, + TestComponentBuilder, + beforeEach, + beforeEachProviders +} from 'angular2/testing'; +import {provide} from 'angular2/core'; +import {AngularFire, FIREBASE_PROVIDERS, defaultFirebase} from 'angularfire2'; +import {Github} from './github'; +import {HTTP_PROVIDERS, XHRBackend, Response, BaseResponseOptions} from 'angular2/http'; +import {MockBackend, MockConnection} from 'angular2/http/testing'; +import {LOCAL_STORAGE} from '../config'; +import {ScalarObservable} from 'rxjs/observable/ScalarObservable'; + +describe('Github Service', () => { + + beforeEachProviders(() => [ + Github, + FIREBASE_PROVIDERS, + defaultFirebase('https://issue-zero.firebaseio.com'), + HTTP_PROVIDERS, + provide(XHRBackend, { + useClass: MockBackend + }), + provide(LOCAL_STORAGE, { + useClass: MockLocalStorage + })]); + + beforeEach(inject([AngularFire], (angularFire) => { + angularFire.auth = new ScalarObservable({github: { + accessToken: 'fooAccessToken' + }}) + })); + + + it('should make a request to the Github API', inject([Github, XHRBackend], (service: Github, backend: MockBackend) => { + var nextSpy = jasmine.createSpy('next') + backend.connections.subscribe(nextSpy); + + var fetchObservable = service.fetch('/repo', 'bar=baz'); + expect(nextSpy).not.toHaveBeenCalled(); + fetchObservable.subscribe(); + expect(nextSpy).toHaveBeenCalled(); + })); + + + it('should not make a request to the Github API if value exists in cache', inject( + [Github, XHRBackend, LOCAL_STORAGE], + (service: Github, backend: MockBackend, localStorage) => { + var connectionCreated = jasmine.createSpy('connectionCreated'); + var valueReceived = jasmine.createSpy('valueReceived'); + backend.connections.subscribe(connectionCreated); + + localStorage.setItem('izCache/repo', '{"issues": ["1"]}'); + + var fetchObservable = service.fetch('/repo', 'bar=baz'); + expect(connectionCreated).not.toHaveBeenCalled(); + fetchObservable.subscribe(valueReceived); + expect(connectionCreated).not.toHaveBeenCalled(); + expect(valueReceived.calls.argsFor(0)[0]).toEqual({issues: ['1']}) + })); + + it('should set http response json to cache', inject( + [Github, XHRBackend, LOCAL_STORAGE], + (service: Github, backend: MockBackend, localStorage) => { + var setItemSpy = spyOn(localStorage, 'setItem'); + backend.connections.subscribe((c:MockConnection) => { + c.mockRespond(new Response(new BaseResponseOptions().merge({body: `{"issues": ["1","2","3"]}`}))); + }); + + var fetchObservable = service.fetch('/repo', 'bar=baz'); + fetchObservable.subscribe(); + expect(setItemSpy).toHaveBeenCalledWith('izCache/repo', '{"issues": ["1","2","3"]}'); + })); +}); + +class MockLocalStorage { + private _cache = {}; + getItem (key:string): string { + return key in this._cache ? this._cache[key] : null; + } + + setItem (key:string, value:string): void { + this._cache[key] = value; + } +} diff --git a/src/client/app/github/github.ts b/src/client/app/github/github.ts new file mode 100644 index 0000000..d1d37a0 --- /dev/null +++ b/src/client/app/github/github.ts @@ -0,0 +1,66 @@ +import {AngularFire} from 'angularfire2'; +import {Inject, Injectable} from 'angular2/core'; +import {Http} from 'angular2/http'; +import {Observable} from 'rxjs/Observable'; +import {ScalarObservable} from 'rxjs/observable/ScalarObservable'; +import {ErrorObservable} from 'rxjs/observable/ErrorObservable'; +import {_catch} from 'rxjs/operator/catch'; +import {map} from 'rxjs/operator/map'; +import {_do} from 'rxjs/operator/do'; +import {mergeMap} from 'rxjs/operator/mergeMap'; + +import {User, Repo} from './types'; +import {LOCAL_STORAGE} from '../config'; + +const GITHUB_API = 'https://api.github.com'; + +interface LocalStorage { + getItem(key:string): string; + setItem(key:string, value:string): void; +} + +@Injectable() +export class Github { + + constructor( + private _http:Http, + @Inject(LOCAL_STORAGE) private _localStorage:LocalStorage, + private _af:AngularFire) {} + + fetch(path:string, params?: string): Observable { + var accessToken = map.call(this._af.auth, (auth:FirebaseAuthData) => auth.github.accessToken); + var httpReq = mergeMap.call(accessToken, (tokenValue) => this._httpRequest(path, tokenValue, params)); + return _catch.call(this._getCache(path), () => httpReq); + } + + _httpRequest (path:string, accessToken:string, params?:string) { + var url = `${GITHUB_API}${path}?${params ? params + '&' : ''}access_token=${accessToken}` + var reqObservable = this._http.get(url); + // Set the http response to cache + // TODO(jeffbcross): issues should be cached in more structured and queryable format + var setCacheSideEffect = _do.call(reqObservable, res => this._setCache(path, res.text())); + // Get the JSON object from the response + return map.call(setCacheSideEffect, res => res.json()); + } + + /** + * TODO(jeffbcross): get rid of this for a more sophisticated, queryable cache + */ + _getCache (path:string): Observable { + var cacheKey = `izCache${path}`; + var cache = this._localStorage.getItem(cacheKey); + if (cache) { + return new ScalarObservable(JSON.parse(cache)); + } else { + return ErrorObservable.create(null); + } + } + + /** + * TODO(jeffbcross): get rid of this for a more sophisticated, queryable cache + */ + _setCache(path:string, value:string): void { + var cacheKey = `izCache${path}`; + this._localStorage.setItem(cacheKey, value); + } +} diff --git a/src/client/app/github/types.ts b/src/client/app/github/types.ts new file mode 100644 index 0000000..2a3b3e5 --- /dev/null +++ b/src/client/app/github/types.ts @@ -0,0 +1,15 @@ +export type Repo = { + full_name: string; + owner: User; +} + +export type User = { + avatar_url: string; + login: string; +} + +export enum GithubObjects { + User, + Repo, + Issue +} \ No newline at end of file diff --git a/src/client/app/issue-cli.spec.ts b/src/client/app/issue-cli.spec.ts deleted file mode 100644 index 545c689..0000000 --- a/src/client/app/issue-cli.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -import {describe, it, expect, beforeEachProviders, inject} from 'angular2/testing'; -import {IssueCliApp} from '../app/issue-cli'; - -beforeEachProviders(() => [IssueCliApp]); - -describe('App: IssueCli', () => { - it('should have the `defaultMeaning` as 42', - inject( - [IssueCliApp], (app: IssueCliApp) => { - // expect(app.defaultMeaning).toBe(42); - })); - - describe('#meaningOfLife', () => { - it('should get the meaning of life', - inject( - [IssueCliApp], (app: IssueCliApp) => { - // expect(app.meaningOfLife()).toBe('The meaning of life is 42'); - // expect(app.meaningOfLife(22)).toBe('The meaning of life is 22'); - })); - }); -}); diff --git a/src/client/app/issues/issues.ts b/src/client/app/issues/issues.ts index a4802c9..cb2f7c2 100644 --- a/src/client/app/issues/issues.ts +++ b/src/client/app/issues/issues.ts @@ -4,10 +4,11 @@ import {List} from './list/list'; @Component({ providers: [], - template: `Issues route` + template: ``, + directives: [RouterOutlet] }) @RouteConfig([ - {path: '/list/...', name: 'List', component: List, useAsDefault: true}, + {path: '/list', name: 'List', component: List, useAsDefault: true}, ]) export class Issues { constructor() {} diff --git a/src/client/app/issues/list/issue-row/issue-row.ts b/src/client/app/issues/list/issue-row/issue-row.ts new file mode 100644 index 0000000..b5c2927 --- /dev/null +++ b/src/client/app/issues/list/issue-row/issue-row.ts @@ -0,0 +1,40 @@ +import {Component, EventEmitter, Input, Output} from 'angular2/core'; +import {MD_LIST_DIRECTIVES} from '@angular2-material/list'; + +// truncate.ts +import {Pipe} from 'angular2/core' + +@Pipe({ + name: 'truncate' +}) +export class TruncatePipe { + transform(value: string, args: string[]) : string { + let limit = args.length > 0 ? parseInt(args[0], 10) : 10; + let trail = args.length > 1 ? args[1] : '...'; + + return value.length > limit ? value.substring(0, limit) + trail : value; + } +} + + +@Component({ + selector: 'issue-row', + template: ` + + {{issue.user.login}} logo + {{issue.title}} +

+ @{{issue.user.login}} -- {{issue.body | truncate: 140 : '...' }} +

+ + `, + providers: [], + directives: [MD_LIST_DIRECTIVES], + pipes: [TruncatePipe] +}) +export class IssueRow { + @Input('issue') issue:any; + + constructor() {} + +} diff --git a/src/client/app/issues/list/list.ts b/src/client/app/issues/list/list.ts index 56b8956..9eec7f6 100644 --- a/src/client/app/issues/list/list.ts +++ b/src/client/app/issues/list/list.ts @@ -1,8 +1,75 @@ -import {Component} from 'angular2/core'; -import {RouteConfig, RouterOutlet} from 'angular2/router'; +import {Component, ChangeDetectionStrategy} from 'angular2/core'; +import {Observable} from 'rxjs/Observable'; +import {Subject} from 'rxjs/Subject'; +import {BehaviorSubject} from 'rxjs/subject/BehaviorSubject'; +import {MD_LIST_DIRECTIVES} from '@angular2-material/list'; -@Component({template: '', providers: [], directives: [RouterOutlet]}) -@RouteConfig([]) +import {IssueListToolbar} from './toolbar/toolbar'; +import {IssueRow} from './issue-row/issue-row'; +import {RepoSelectorRow} from './repo-selector-row/repo-selector-row'; + +import {GithubObjects, Repo, User} from '../../github/types'; +import {Github} from '../../github/github'; + +@Component({ + styles: [` + md-list-item { + background-color: rgb(245, 245, 245) + } + `], + template: ` + + + + + + + + + + + + + `, + providers: [Github], + directives: [MD_LIST_DIRECTIVES, IssueListToolbar, IssueRow, RepoSelectorRow], + pipes: [], + changeDetection: ChangeDetectionStrategy.OnPush +}) export class List { - constructor() {} + issues: Observable; + repoSelectorActive:boolean = false; + repos: Observable; + repoSelection: Subject; + constructor(gh:Github) { + this.repos = gh.fetch(`/user/repos`, 'affiliation=owner,collaborator&sort=updated'); + + this.repoSelection = new BehaviorSubject(null); + this.repos + .map((repos:Repo[]) => repos[0]) + .take(1) + .subscribe(repo => this.repoSelection.next(repo)); + + this.issues = this.repoSelection + .filter(v => !!v) + // Select the first repo, most-recently updated + .switchMap((repo:Repo) => gh.fetch(`/repos/${repo.full_name}/issues`)); + } + + toggleRepoSelector() { + this.repoSelectorActive = !this.repoSelectorActive; + } + + getSmallAvatar(repo:Repo):string { + return repo ? `${repo.owner.avatar_url}&s=40` : ''; + } } diff --git a/src/client/app/issues/list/repo-selector-row/repo-selector-row.ts b/src/client/app/issues/list/repo-selector-row/repo-selector-row.ts new file mode 100644 index 0000000..8b8aebe --- /dev/null +++ b/src/client/app/issues/list/repo-selector-row/repo-selector-row.ts @@ -0,0 +1,23 @@ +import {Component, EventEmitter, Input, Output} from 'angular2/core'; +import {MD_LIST_DIRECTIVES} from '@angular2-material/list'; + +@Component({ + selector: 'repo-selector-row', + styles: [` + .md-list-item { + background-color: rgb(245, 245, 245) + } + `], + template: ` + + {{repo.owner.login}} logo + {{repo.full_name}} + + `, + providers: [], + directives: [MD_LIST_DIRECTIVES], + pipes: [] +}) +export class RepoSelectorRow { + @Input('repo') repo:any; +} diff --git a/src/client/app/issues/list/toolbar/toolbar.ts b/src/client/app/issues/list/toolbar/toolbar.ts new file mode 100644 index 0000000..8c1cea3 --- /dev/null +++ b/src/client/app/issues/list/toolbar/toolbar.ts @@ -0,0 +1,38 @@ +import {Component, EventEmitter, Input, Output} from 'angular2/core'; +import {MdToolbar} from '@angular2-material/toolbar'; + +@Component({ + selector: 'issue-list-toolbar', + template: ` +
+ + Select Repository + + arrow_drop_up + + + + logo for {{repo.owner.login}} + {{repo.full_name}} + + arrow_drop_down + + +
+ `, + styles: [` + md-toolbar { + cursor: pointer; + } + md-toolbar img { + margin-right: 16px; + } + `], + providers: [], + directives: [MdToolbar], + pipes: [] +}) +export class IssueListToolbar { + @Input('repo') repo:any; + @Input('repoSelector') repoSelector:boolean; +} diff --git a/src/client/system.config.js b/src/client/system.config.js index 59bfb2d..dbadb56 100644 --- a/src/client/system.config.js +++ b/src/client/system.config.js @@ -48,6 +48,11 @@ System.config({ format: 'cjs', defaultExtension: 'js', main: 'progress-circle.js' + }, + '@angular2-material/list': { + format: 'cjs', + defaultExtension: 'js', + main: 'list.js' } } });