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

Basic Issue List Implementation with localStorage caching #49

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions karma-test-shim.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 0 additions & 1 deletion karma.conf.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 9 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.*",
Expand Down
7 changes: 6 additions & 1 deletion src/client/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -27,6 +29,9 @@ bootstrap(IssueCliApp, [
provide(IS_POST_LOGIN, {
useValue: (<any>window).__IS_POST_LOGIN
}),
provide(LOCAL_STORAGE, {
useValue: (<any>window.localStorage)
}),
firebaseAuthConfig(
{provider: AuthProviders.Github, method: AuthMethods.Redirect, scope: ['repo']}),
HTTP_PROVIDERS
Expand Down
1 change: 1 addition & 0 deletions src/client/app/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
94 changes: 94 additions & 0 deletions src/client/app/github/github.spec.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
66 changes: 66 additions & 0 deletions src/client/app/github/github.ts
Original file line number Diff line number Diff line change
@@ -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<Repo[]> {
Copy link
Member

@alxhub alxhub Apr 22, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Try this pattern:

function getFromCache(x): Observable
function getFromNetwork(x): Observable

Observable
.concat(
getFromCache(x),
getFromNetwork(x)
.do(v => setCache(x, v)))
.filter(v => v != undefined)
.first()

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<Repo> {
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);
}
}
15 changes: 15 additions & 0 deletions src/client/app/github/types.ts
Original file line number Diff line number Diff line change
@@ -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
}
21 changes: 0 additions & 21 deletions src/client/app/issue-cli.spec.ts

This file was deleted.

5 changes: 3 additions & 2 deletions src/client/app/issues/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import {List} from './list/list';

@Component({
providers: [],
template: `Issues route`
template: `<router-outlet></router-outlet>`,
directives: [RouterOutlet]
})
@RouteConfig([
{path: '/list/...', name: 'List', component: List, useAsDefault: true},
{path: '/list', name: 'List', component: List, useAsDefault: true},
])
export class Issues {
constructor() {}
Expand Down
40 changes: 40 additions & 0 deletions src/client/app/issues/list/issue-row/issue-row.ts
Original file line number Diff line number Diff line change
@@ -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: `
<md-list-item>
<img md-list-avatar [src]="issue.user.avatar_url + '&s=40'" alt="{{issue.user.login}} logo">
<span md-line> {{issue.title}} </span>
<p md-line>
@{{issue.user.login}} -- {{issue.body | truncate: 140 : '...' }}
</p>
<md-list-item>
`,
providers: [],
directives: [MD_LIST_DIRECTIVES],
pipes: [TruncatePipe]
})
export class IssueRow {
@Input('issue') issue:any;

constructor() {}

}
Loading