├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── app ├── app.component.ts ├── app.module.ts ├── components │ ├── proximity-selector.component.ts │ └── search-box.component.ts ├── main.ts ├── models │ ├── current-search.model.ts │ └── search-result.model.ts ├── reducers │ └── search.reducer.ts ├── services │ └── youtube.service.ts └── styles.css ├── index.html ├── package.json ├── systemjs.config.js ├── tsconfig.json └── typings.json /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | node_modules/ 3 | *.log 4 | /typings/ 5 | .idea 6 | /dist/ 7 | *.lock 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "4.2" 4 | before_script: 5 | - npm install 6 | script: 7 | - npm run tsc 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 SitePoint 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/pietro909/one-source-of-truth-for-angular.svg?branch=youtube)](https://travis-ci.org/pietro909/one-source-of-truth-for-angular) 2 | # One Source of Truth for Angular 2 3 | 4 | This repository contains the code for the article published on SitePoint [Managing State in Angular 2](https://www.sitepoint.com/managing-state-angular-2-ngrx/) 5 | 6 | The components we build for our web-application often host states. Connecting components often means sharing mutable states: this is difficult to manage and leads to inconsistency. 7 | 8 | What if we have one place for the state's mutation and let messages do the rest? [ngrx/store](https://github.com/ngrx/store) is an implementation of [Redux](https://github.com/reactjs/redux) for [Angular](https://angular.io/) using [RxJS](http://reactivex.io/rxjs/) that brings this powerful pattern into the Angular world. 9 | 10 | This sample application will manage a realtime search using the [YouTube API](https://developers.google.com/youtube/v3/), allowing the user to search for a name and to geo-localize the search results. 11 | 12 | Play with the live example here: [live example](https://pietro909.github.io/one-source-of-truth-for-angular/). 13 | 14 | ## Requirements 15 | 16 | * [Node.js](http://nodejs.org/) v5.x.x or higher 17 | * [NPM](https://www.npmjs.com/) 3.x.x or higher 18 | 19 | ## Installation Steps 20 | 21 | 1. Clone repo 22 | 2. Run `npm install` 23 | 3. Run `npm start` 24 | 4. Visit http://localhost:3000/ 25 | 26 | ## License 27 | 28 | The MIT License (MIT) 29 | 30 | Copyright (c) 2016 SitePoint 31 | 32 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 33 | 34 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 35 | 36 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 37 | -------------------------------------------------------------------------------- /app/app.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, OnInit} from '@angular/core'; 2 | import {Observable} from "rxjs"; 3 | import {Store} from "@ngrx/store"; 4 | 5 | import {CurrentSearch} from "./models/current-search.model"; 6 | import {SearchResult} from "./models/search-result.model"; 7 | import {YouTubeService} from "./services/youtube.service"; 8 | 9 | @Component({ 10 | selector: 'my-app', 11 | template: ` 12 |
13 |

{{title}}

14 |
15 | 16 | 18 |
19 |
20 |

Can't use geolocalization with an empty searchbox

21 |
22 |
23 |

{{ errorLocationMessage }}

24 |
25 |
26 |

27 | Try to type something in the searchbox, play with the location and with radius: the above state will 28 | always be consistent and up to date. 29 |

30 |

{{ state | json }}

31 |

state is empty

32 |

Search results:

33 |
34 |
35 |

No results

36 |
37 |
38 |
39 |
40 |

{{ result.title }}

41 |
42 | 43 |
44 |
45 |
46 | ` 47 | }) 48 | export class AppComponent implements OnInit { 49 | 50 | title = 'One Source of Truth for Angular 2'; 51 | 52 | private state: CurrentSearch; 53 | private currentSearch: Observable; 54 | private searchResults: SearchResult[] = []; 55 | private disableSearch = false; 56 | private errorEmptySearch = true; 57 | private errorLocation = false; 58 | private errorLocationMessage = ''; 59 | 60 | constructor( 61 | private store: Store, 62 | private youtube: YouTubeService 63 | ) { 64 | this.currentSearch = this.store.select('currentSearch'); 65 | this.youtube.searchResults.subscribe((results: SearchResult[]) => this.searchResults = results); 66 | } 67 | 68 | ngOnInit() { 69 | this.currentSearch.subscribe((state: CurrentSearch) => { 70 | this.state = state; 71 | if (state && state.name && state.name.length > 0) { 72 | this.disableSearch = false; 73 | this.errorEmptySearch = false; 74 | this.youtube.search(state) 75 | } else { 76 | this.disableSearch = true; 77 | this.errorEmptySearch = true; 78 | this.searchResults = []; 79 | } 80 | if (state && state.error) { 81 | this.errorLocation = true; 82 | this.errorLocationMessage = state.error; 83 | } else { 84 | this.errorLocation = false; 85 | } 86 | }); 87 | } 88 | 89 | } 90 | -------------------------------------------------------------------------------- /app/app.module.ts: -------------------------------------------------------------------------------- 1 | import {NgModule} from '@angular/core'; 2 | import {BrowserModule} from '@angular/platform-browser'; 3 | import {HttpModule} from "@angular/http"; 4 | import {Store, StoreModule} from "@ngrx/store"; 5 | 6 | import {AppComponent} from "./app.component"; 7 | import {YouTubeService} from "./services/youtube.service"; 8 | import {ProximitySelector} from "./components/proximity-selector.component"; 9 | import {SearchBox} from "./components/search-box.component"; 10 | import {SearchReducer} from "./reducers/search.reducer"; 11 | 12 | const storeManager = StoreModule.provideStore({ currentSearch: SearchReducer }); 13 | 14 | @NgModule({ 15 | imports: [ BrowserModule, HttpModule, StoreModule, storeManager ], 16 | declarations: [ AppComponent, SearchBox, ProximitySelector ], 17 | bootstrap: [ AppComponent ], 18 | providers: [ YouTubeService ] 19 | }) 20 | export class AppModule { } 21 | -------------------------------------------------------------------------------- /app/components/proximity-selector.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, Input} from '@angular/core'; 2 | import {Store} from '@ngrx/store'; 3 | 4 | @Component({ 5 | selector: 'proximity-selector', 6 | template: ` 7 |
8 | 12 | 16 |
17 |
18 | 22 | 26 |
27 | ` 28 | }) 29 | 30 | export class ProximitySelector { 31 | 32 | static StoreEvents = { 33 | position: 'ProximitySelector:POSITION', 34 | radius: 'ProximitySelector:RADIUS', 35 | off: 'ProximitySelector:OFF', 36 | error: 'ProximitySelector:ERROR' 37 | }; 38 | 39 | @Input() 40 | store: Store; 41 | 42 | @Input() 43 | disabled: boolean; 44 | 45 | active = false; 46 | 47 | onLocation($event: any) { 48 | if ($event.target.checked) { 49 | navigator.geolocation.getCurrentPosition( 50 | (position: any) => { 51 | this.active = true; 52 | this.store.dispatch({ 53 | type: ProximitySelector.StoreEvents.position, 54 | payload: { 55 | position: { 56 | latitude: position.coords.latitude, 57 | longitude: position.coords.longitude 58 | } 59 | } 60 | }); 61 | }, 62 | (error: any) => { 63 | this.disabled = true; 64 | this.active = false; 65 | this.store.dispatch({ 66 | type: ProximitySelector.StoreEvents.error, 67 | payload: { 68 | message: error.message 69 | } 70 | }); 71 | } 72 | ); 73 | } else { 74 | this.active = false; 75 | this.store.dispatch({ 76 | type: ProximitySelector.StoreEvents.off, 77 | payload: {} 78 | }); 79 | } 80 | } 81 | 82 | onRadius($event: any) { 83 | const radius = parseInt($event.target.value, 10); 84 | this.store.dispatch({ 85 | type: ProximitySelector.StoreEvents.radius, 86 | payload: { 87 | radius: radius 88 | } 89 | }); 90 | } 91 | 92 | } 93 | -------------------------------------------------------------------------------- /app/components/search-box.component.ts: -------------------------------------------------------------------------------- 1 | import {Observable} from 'rxjs/Rx'; 2 | import {ElementRef, OnInit, Component, Input} from '@angular/core'; 3 | import {Store} from '@ngrx/store'; 4 | 5 | @Component({ 6 | selector: 'search-box', 7 | template: ` 8 | 9 | ` 10 | }) 11 | 12 | export class SearchBox implements OnInit { 13 | 14 | static StoreEvents = { 15 | text: 'SearchBox:TEXT_CHANGED' 16 | }; 17 | 18 | @Input() 19 | store: Store; 20 | 21 | constructor(private el: ElementRef) {} 22 | 23 | ngOnInit(): void { 24 | Observable.fromEvent(this.el.nativeElement, 'keyup') 25 | .map((e: any) => e.target.value) 26 | .debounceTime(500) 27 | .subscribe((text: string) => 28 | this.store.dispatch({ 29 | type: SearchBox.StoreEvents.text, 30 | payload: { 31 | text: text 32 | } 33 | }) 34 | ); 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /app/main.ts: -------------------------------------------------------------------------------- 1 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 2 | import { AppModule } from './app.module'; 3 | 4 | const platform = platformBrowserDynamic(); 5 | 6 | platform.bootstrapModule(AppModule); 7 | -------------------------------------------------------------------------------- /app/models/current-search.model.ts: -------------------------------------------------------------------------------- 1 | export interface CurrentSearch { 2 | name: string; 3 | location?: { 4 | latitude: number, 5 | longitude: number 6 | }, 7 | radius: number, 8 | error?: string 9 | } 10 | -------------------------------------------------------------------------------- /app/models/search-result.model.ts: -------------------------------------------------------------------------------- 1 | export interface SearchResult { 2 | id: string; 3 | title: string; 4 | thumbnailUrl: string; 5 | } 6 | -------------------------------------------------------------------------------- /app/reducers/search.reducer.ts: -------------------------------------------------------------------------------- 1 | import { ActionReducer, Action } from '@ngrx/store'; 2 | import {SearchBox} from '../components/search-box.component'; 3 | import {ProximitySelector} from '../components/proximity-selector.component'; 4 | import {CurrentSearch} from "../models/current-search.model"; 5 | 6 | export const SearchReducer: ActionReducer = (state: CurrentSearch, action: Action) => { 7 | switch (action.type) { 8 | case SearchBox.StoreEvents.text: 9 | return Object.assign({}, state, { 10 | name: action.payload.text 11 | }); 12 | case ProximitySelector.StoreEvents.position: 13 | return Object.assign({}, state, { 14 | location: { 15 | latitude: action.payload.position.latitude, 16 | longitude: action.payload.position.longitude 17 | }, 18 | error: null 19 | }); 20 | case ProximitySelector.StoreEvents.radius: 21 | return Object.assign({}, state, { 22 | radius: action.payload.radius 23 | }); 24 | case ProximitySelector.StoreEvents.off: 25 | return Object.assign({}, state, { 26 | location: null, 27 | error: null 28 | }); 29 | case ProximitySelector.StoreEvents.error: 30 | return Object.assign({}, state, { 31 | error: action.payload.message 32 | }); 33 | default: 34 | return state; 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /app/services/youtube.service.ts: -------------------------------------------------------------------------------- 1 | import {Observable, BehaviorSubject} from 'rxjs/Rx'; 2 | import {Injectable} from '@angular/core'; 3 | import {Response, Http} from '@angular/http'; 4 | import {SearchResult} from '../models/search-result.model'; 5 | import {CurrentSearch} from '../models/current-search.model'; 6 | 7 | const YOUTUBE_API_KEY = 'AIzaSyDOfT_BO81aEZScosfTYMruJobmpjqNeEk'; 8 | const YOUTUBE_API_URL = 'https://www.googleapis.com/youtube/v3/search'; 9 | const LOCATION_TEMPLATE = 'location={latitude},{longitude}&locationRadius={radius}km'; 10 | 11 | 12 | @Injectable() 13 | export class YouTubeService { 14 | 15 | searchResults: BehaviorSubject = new BehaviorSubject([]); 16 | 17 | constructor( private http: Http ) {} 18 | 19 | search(query: CurrentSearch): Observable { 20 | let params = [ 21 | `q=${query.name}`, 22 | `key=${YOUTUBE_API_KEY}`, 23 | `part=snippet`, 24 | `type=video`, 25 | `maxResults=50` 26 | ]; 27 | 28 | if (query.location) { 29 | const radius = query.radius ? query.radius : 50; 30 | const location = 31 | LOCATION_TEMPLATE 32 | .replace(/\{latitude\}/g, query.location.latitude.toString()) 33 | .replace(/\{longitude\}/g, query.location.longitude.toString()) 34 | .replace(/\{radius\}/g, radius.toString()); 35 | params.push(location); 36 | } 37 | 38 | const queryUrl: string = `${YOUTUBE_API_URL}?${params.join('&')}`; 39 | 40 | console.log(queryUrl); 41 | 42 | this.http.get(queryUrl) 43 | .map((response: Response) => { 44 | console.log(response); 45 | return response.json().items.map(item => { 46 | return { 47 | id: item.id.videoId, 48 | title: item.snippet.title, 49 | thumbnailUrl: item.snippet.thumbnails.high.url 50 | }; 51 | }); 52 | }) 53 | .subscribe((results: SearchResult[]) => this.searchResults.next(results)); 54 | 55 | return this.searchResults; 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /app/styles.css: -------------------------------------------------------------------------------- 1 | my-app { 2 | display: flex; 3 | } 4 | section { 5 | margin: auto; 6 | } 7 | h1 { 8 | color: #369; 9 | font-family: Arial, Helvetica, sans-serif; 10 | font-size: 250%; 11 | } 12 | h2, h3 { 13 | color: #444; 14 | font-family: Arial, Helvetica, sans-serif; 15 | font-weight: lighter; 16 | } 17 | body { 18 | margin: 2em; 19 | } 20 | search-box, proximity-selector { 21 | display: block; 22 | margin-top: 1.2rem; 23 | } 24 | .state { 25 | font-family: monospace; 26 | font-size: smaller; 27 | } 28 | .row { 29 | margin-bottom: 1.6rem; 30 | margin-top: 1.6rem; 31 | } 32 | label.disabled { 33 | color: darkgray; 34 | } 35 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | One source of truth for Angular 2 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 18 | 19 | 20 | 21 | 22 | Fork me on GitHub 25 | 26 | Loading... 27 | 28 | 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "osotfa", 3 | "version": "0.0.0", 4 | "description": "", 5 | "author": "Pietro Grandi ", 6 | "homepage": "", 7 | "license": "ISC", 8 | "scripts": { 9 | "start": "tsc && concurrently \"npm run tsc:w\" \"npm run lite\" ", 10 | "lite": "lite-server", 11 | "postinstall": "typings install", 12 | "tsc": "tsc", 13 | "tsc:w": "tsc -w", 14 | "typings": "typings" 15 | }, 16 | "dependencies": { 17 | "@angular/common": "2.0.0", 18 | "@angular/compiler": "2.0.0", 19 | "@angular/core": "2.0.0", 20 | "@angular/forms": "2.0.0", 21 | "@angular/http": "2.0.0", 22 | "@angular/platform-browser": "2.0.0", 23 | "@angular/platform-browser-dynamic": "2.0.0", 24 | "@angular/router": "3.0.0", 25 | "@angular/upgrade": "2.0.0", 26 | "systemjs": "0.19.27", 27 | "core-js": "^2.4.1", 28 | "reflect-metadata": "^0.1.3", 29 | "rxjs": "5.0.0-beta.12", 30 | "zone.js": "^0.6.23", 31 | "angular2-in-memory-web-api": "0.0.20", 32 | "bootstrap": "^3.3.6", 33 | "es6-shim": "^0.35.0", 34 | "@ngrx/core": "^1.2.0", 35 | "@ngrx/store": "^2.2.1" 36 | }, 37 | "devDependencies": { 38 | "concurrently": "^2.2.0", 39 | "lite-server": "^2.2.0", 40 | "typescript": "^2.0.2", 41 | "typings": "^1.0.4" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /systemjs.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * System configuration for Angular 2 samples 3 | * Adjust as necessary for your application needs. 4 | */ 5 | (function (global) { 6 | System.config({ 7 | paths: { 8 | // paths serve as alias 9 | 'npm:': 'node_modules/' 10 | }, 11 | // map tells the System loader where to look for things 12 | map: { 13 | // our app is within the app folder 14 | app: 'dist', 15 | // angular bundles 16 | '@angular/core': 'npm:@angular/core/bundles/core.umd.js', 17 | '@angular/common': 'npm:@angular/common/bundles/common.umd.js', 18 | '@angular/compiler': 'npm:@angular/compiler/bundles/compiler.umd.js', 19 | '@angular/platform-browser': 'npm:@angular/platform-browser/bundles/platform-browser.umd.js', 20 | '@angular/platform-browser-dynamic': 'npm:@angular/platform-browser-dynamic/bundles/platform-browser-dynamic.umd.js', 21 | '@angular/http': 'npm:@angular/http/bundles/http.umd.js', 22 | '@angular/router': 'npm:@angular/router/bundles/router.umd.js', 23 | '@angular/forms': 'npm:@angular/forms/bundles/forms.umd.js', 24 | // other libraries 25 | 'rxjs': 'npm:rxjs', 26 | 'angular2-in-memory-web-api': 'npm:angular2-in-memory-web-api', 27 | '@ngrx': 'npm:@ngrx' 28 | }, 29 | // packages tells the System loader how to load when no filename and/or no extension 30 | packages: { 31 | app: { 32 | main: './main.js', 33 | defaultExtension: 'js' 34 | }, 35 | rxjs: { 36 | defaultExtension: 'js' 37 | }, 38 | 39 | 'angular2-in-memory-web-api': { 40 | main: './index.js', 41 | defaultExtension: 'js' 42 | }, 43 | '@ngrx/core': { main: 'bundles/core.umd.js', defaultExtension: 'js' }, 44 | '@ngrx/store': { main: 'bundles/store.umd.js', defaultExtension: 'js' } 45 | } 46 | }); 47 | })(this); 48 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "sourceMap": true, 7 | "emitDecoratorMetadata": true, 8 | "experimentalDecorators": true, 9 | "removeComments": false, 10 | "noImplicitAny": false, 11 | "outDir": "./dist" 12 | }, 13 | "exclude": [ 14 | "node_modules", 15 | "typings/main", 16 | "typings/main.d.ts", 17 | "quickstart", 18 | "old" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /typings.json: -------------------------------------------------------------------------------- 1 | { 2 | "globalDependencies": { 3 | "core-js": "registry:dt/core-js#0.0.0+20160725163759", 4 | "jasmine": "registry:dt/jasmine#2.2.0+20160621224255", 5 | "node": "registry:dt/node#6.0.0+20160909174046" 6 | } 7 | } 8 | --------------------------------------------------------------------------------