From 431cfbbbc2a04b2267ec224e2c2ca4ca1d1efaa0 Mon Sep 17 00:00:00 2001 From: johnlindquist Date: Mon, 9 May 2016 17:24:05 -0600 Subject: [PATCH 1/2] An example of moving validation into the templates and using .ng-invalid for styles. --- .../app/movies/movie-edit.component.html | 118 ++++++++++++------ .../app/movies/movie-edit.component.ts | 107 ++++------------ .../app/shared/number.validator.directive.ts | 26 ++++ MovieHunter/app/shared/number.validator.ts | 26 ++-- 4 files changed, 143 insertions(+), 134 deletions(-) create mode 100644 MovieHunter/app/shared/number.validator.directive.ts diff --git a/MovieHunter/app/movies/movie-edit.component.html b/MovieHunter/app/movies/movie-edit.component.html index 0a139b9..8533c9c 100644 --- a/MovieHunter/app/movies/movie-edit.component.html +++ b/MovieHunter/app/movies/movie-edit.component.html @@ -1,3 +1,11 @@ + + +
{{pageTitle}} @@ -6,62 +14,102 @@
-
+
- - - {{formError.title}} + + + Movie title is required + + + Movie title must be at least three characters. + + Movie title cannot exceed 50 characters. + + +
- -
+ +
- - - {{formError.director}} + + + Director is required + + + Director must be at least 5 characters. + + + Director cannot exceed 50 characters.
- -
+ +
- - - {{formError.starRating}} + + + Rating is required. + + + Rating must be greater than 0. + + + Rating must be less than 5. + + + Rating must be a number.
- -
+ +
- - - {{ formError.description}} + + + A description is required.
diff --git a/MovieHunter/app/movies/movie-edit.component.ts b/MovieHunter/app/movies/movie-edit.component.ts index f1c02bb..1185f50 100644 --- a/MovieHunter/app/movies/movie-edit.component.ts +++ b/MovieHunter/app/movies/movie-edit.component.ts @@ -1,65 +1,40 @@ -import { Component } from '@angular/core'; -import { FormBuilder, ControlGroup, Control, Validators } from '@angular/common'; -import { ROUTER_DIRECTIVES, OnActivate, RouteSegment } from '@angular/router'; - -import { IMovie } from './movie'; -import { MovieService } from './movie.service'; -import { NumberValidator } from '../shared/number.validator'; +import {Component} from '@angular/core'; +import {FormBuilder, ControlGroup, Control} from '@angular/common'; +import {ROUTER_DIRECTIVES, OnActivate, RouteSegment} from '@angular/router'; +import {IMovie} from './movie'; +import {MovieService} from './movie.service'; +import {RangeValidator} from '../shared/number.validator.directive'; @Component({ templateUrl: 'app/movies/movie-edit.component.html', - directives: [ROUTER_DIRECTIVES] + directives: [ROUTER_DIRECTIVES, RangeValidator] }) export class MovieEditComponent implements OnActivate { - pageTitle: string = 'Edit Movie'; - editForm: ControlGroup; - titleControl: Control; - formError: { [id: string]: string }; - private _validationMessages: { [id: string]: { [id: string]: string } }; - movie: IMovie; - errorMessage: string; - - constructor(private _fb: FormBuilder, - private _movieService: MovieService) { + pageTitle:string = 'Edit Movie'; + editForm:ControlGroup; + titleControl:Control; + movie:IMovie; + errorMessage:string; - // Initialization of strings - this.formError = { - 'title': '', - 'director': '', - 'starRating': '', - 'description': '' - }; + errors; - this._validationMessages = { - 'title': { - 'required': 'Movie title is required', - 'minlength': 'Movie title must be at least three characters.', - 'maxlength': 'Movie title cannot exceed 50 characters.' - }, - 'director': { - 'required': 'Director is required', - 'minlength': 'Director must be at least 5 characters.', - 'maxlength': 'Director cannot exceed 50 characters.' - }, - 'starRating': { - 'range': 'Rate the movie between 1 (lowest) and 5 (highest).' - } - }; + constructor(private _fb:FormBuilder, + private _movieService:MovieService) { } - routerOnActivate(curr: RouteSegment): void { + routerOnActivate(curr:RouteSegment):void { let id = +curr.getParam('id'); this.getMovie(id); } - getMovie(id: number) { + getMovie(id:number) { this._movieService.getMovie(id) .subscribe( - movie => this.onMovieRetrieved(movie), - error => this.errorMessage = error); + movie => this.onMovieRetrieved(movie), + error => this.errorMessage = error); } - onMovieRetrieved(movie: IMovie) { + onMovieRetrieved(movie:IMovie) { this.movie = movie; if (this.movie.movieId === 0) { @@ -68,48 +43,14 @@ export class MovieEditComponent implements OnActivate { this.pageTitle = `Edit Movie: ${this.movie.title}`; } - this.titleControl = new Control(this.movie.title, Validators.compose([Validators.required, - Validators.minLength(3), - Validators.maxLength(50)])); + this.titleControl = new Control(this.movie.title); + this.editForm = this._fb.group({ 'title': this.titleControl, - 'director': [this.movie.director, - Validators.compose([Validators.required, - Validators.minLength(5), - Validators.maxLength(50)])], - 'starRating': [this.movie.starRating, - NumberValidator.range(1, 5)], + 'director': [this.movie.director], + 'starRating': [this.movie.starRating], 'description': [this.movie.description] }); - - this.editForm.valueChanges - .map(value => { - // Causes infinite loop - // this.titleControl.updateValue(value.title.toUpperCase()); - value.title = value.title.toUpperCase(); - return value; - }) - .subscribe(data => this.onValueChanged(data)); - // this.editForm.valueChanges - // .debounceTime(500) - // .subscribe(data => this.onValueChanged(data)); - } - - onValueChanged(data: any) { - for (let field in this.formError) { - if (this.formError.hasOwnProperty(field)) { - let hasError = this.editForm.controls[field].dirty && - !this.editForm.controls[field].valid; - this.formError[field] = ''; - if (hasError) { - for (let key in this.editForm.controls[field].errors) { - if (this.editForm.controls[field].errors.hasOwnProperty(key)) { - this.formError[field] += this._validationMessages[field][key] + ' '; - } - } - } - } - } } saveMovie() { diff --git a/MovieHunter/app/shared/number.validator.directive.ts b/MovieHunter/app/shared/number.validator.directive.ts new file mode 100644 index 0000000..453b113 --- /dev/null +++ b/MovieHunter/app/shared/number.validator.directive.ts @@ -0,0 +1,26 @@ +import {Directive, provide, Attribute, forwardRef} from '@angular/core'; +import {NG_VALIDATORS, Validator, AbstractControl} from '@angular/common'; +import {NumberValidator} from './number.validator'; + +export const RANGE_VALIDATOR = { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => RangeValidator), + multi: true +}; + +@Directive({ + selector: '[range][ngControl]', + providers: [RANGE_VALIDATOR] +}) +export class RangeValidator implements Validator { + private _validator; + + constructor(@Attribute('range') range) { + const [min, max] = range.split(',').map(v => parseInt(v)); + this._validator = NumberValidator.range(min, max); + } + + validate(c:AbstractControl):{} { + return this._validator(c) + } +} \ No newline at end of file diff --git a/MovieHunter/app/shared/number.validator.ts b/MovieHunter/app/shared/number.validator.ts index 407e414..a1f02ec 100644 --- a/MovieHunter/app/shared/number.validator.ts +++ b/MovieHunter/app/shared/number.validator.ts @@ -1,30 +1,24 @@ import { Control } from '@angular/common'; -// For simple validation with no parameters. -// Return type is a key/value pair -interface IValidationResult { - [key: string]: boolean; -} - -// For validation with parameters. -// Return type is a function that takes in a control -// and returns a key/value pair -interface IValidationFunction { - (c: Control): IValidationResult; -} export class NumberValidator { - static range(min: number, max: number): IValidationFunction { + static range(min, max){ return (control: Control): { [key: string]: boolean } => { - if (control.value && (isNaN(control.value) || control.value < min || control.value > max)) { - return { 'range': true }; + if (control.value < min ) { + return { 'min': true }; + } + else if(control.value > max){ + return { 'max' : true } + } + else if(isNaN(control.value)){ + return { 'NaN': true } } return null; }; } - static rangeHardCoded(control: Control): IValidationResult { + static rangeHardCoded(control: Control){ if (control.value && (isNaN(control.value) || control.value < 1 || control.value > 5)) { return { 'range': true }; } From 733d5404bcc7ed9c8b255a589721d8b07d35c504 Mon Sep 17 00:00:00 2001 From: johnlindquist Date: Tue, 10 May 2016 10:00:47 -0600 Subject: [PATCH 2/2] Refactoring messages to streams Removing ngClass in favor of ng-invalid Refactoring movie to streams Refactoring NumberValidator to returns min/max/NaN errors Refactoring the help component --- .../app/movies/movie-edit.component.html | 62 ++------- .../app/movies/movie-edit.component.ts | 118 ++++++++++++++---- MovieHunter/app/movies/movie.service.ts | 3 +- .../app/shared/number.validator.directive.ts | 4 +- MovieHunter/app/shared/number.validator.ts | 2 +- 5 files changed, 108 insertions(+), 81 deletions(-) diff --git a/MovieHunter/app/movies/movie-edit.component.html b/MovieHunter/app/movies/movie-edit.component.html index 8533c9c..5a78ec8 100644 --- a/MovieHunter/app/movies/movie-edit.component.html +++ b/MovieHunter/app/movies/movie-edit.component.html @@ -1,17 +1,16 @@ -
- {{pageTitle}} + {{pageTitle$ | async}}
-
+
@@ -23,25 +22,12 @@ type="text" placeholder="Title (required)" ngControl="title" - #title="ngForm" - minlength="3" - maxlength="50" - required /> - - Movie title is required - - - Movie title must be at least three characters. - - - Movie title cannot exceed 50 characters. - - - +
+
@@ -51,20 +37,9 @@ type="text" placeholder="Director (required)" ngControl="director" - #director="ngForm" - minlength="3" - maxlength="50" - required /> - - Director is required - - - Director must be at least 5 characters. - - - Director cannot exceed 50 characters. - + +
@@ -77,22 +52,9 @@ type="text" placeholder="Rating" ngControl="starRating" - #starRating="ngForm" - range="1,5" - required /> - - Rating is required. - - - Rating must be greater than 0. - - - Rating must be less than 5. - - - Rating must be a number. - + +
@@ -105,12 +67,8 @@ placeholder="Description" rows=3 ngControl="description" - #description="ngForm" - required > - - A description is required. - +
diff --git a/MovieHunter/app/movies/movie-edit.component.ts b/MovieHunter/app/movies/movie-edit.component.ts index 1185f50..1341d92 100644 --- a/MovieHunter/app/movies/movie-edit.component.ts +++ b/MovieHunter/app/movies/movie-edit.component.ts @@ -1,22 +1,37 @@ -import {Component} from '@angular/core'; -import {FormBuilder, ControlGroup, Control} from '@angular/common'; +import {Component, Input} from '@angular/core'; +import {FormBuilder, ControlGroup, Control, Validators, AbstractControl} from '@angular/common'; import {ROUTER_DIRECTIVES, OnActivate, RouteSegment} from '@angular/router'; import {IMovie} from './movie'; import {MovieService} from './movie.service'; import {RangeValidator} from '../shared/number.validator.directive'; +import {Observable} from 'rxjs/Rx'; +import 'rxjs/add/observable/of'; +import 'rxjs/add/observable/from'; +import {NumberValidator} from '../shared/number.validator'; + +@Component({ + selector: 'help-block', + template: ` + {{message}} + ` +}) +export class HelpBlock { + @Input() message:string; +} @Component({ templateUrl: 'app/movies/movie-edit.component.html', - directives: [ROUTER_DIRECTIVES, RangeValidator] + directives: [ROUTER_DIRECTIVES, RangeValidator, HelpBlock] }) export class MovieEditComponent implements OnActivate { - pageTitle:string = 'Edit Movie'; editForm:ControlGroup; - titleControl:Control; - movie:IMovie; - errorMessage:string; + movie$:Observable; + pageTitle$:Observable; - errors; + titleMessage$:Observable; + directorMessage$:Observable; + starRatingMessage$:Observable; + descriptionMessage$:Observable; constructor(private _fb:FormBuilder, private _movieService:MovieService) { @@ -28,35 +43,88 @@ export class MovieEditComponent implements OnActivate { } getMovie(id:number) { - this._movieService.getMovie(id) + this.movie$ = this._movieService.getMovie(id); + + this.pageTitle$ = this.movie$ + .map(({movieId, title}) => movieId + ? `Edit Movie ${title}` + : `Add Movie` + ); + + this.movie$ .subscribe( - movie => this.onMovieRetrieved(movie), - error => this.errorMessage = error); + movie => this.createForm(movie), + console.log.bind(console)); } - onMovieRetrieved(movie:IMovie) { - this.movie = movie; + // if 'INVALID', lookup and push out the validation message + // otherwise, push out an empty string + createMessageStream = (control:AbstractControl, messages:any)=> + control + .statusChanges + .switchMap((status:string) => + status === 'INVALID' + ? Observable.from( + Object.keys(control.errors) + .map(key => messages[key]) + ) + : Observable.of('') + ); - if (this.movie.movieId === 0) { - this.pageTitle = 'Add Movie'; - } else { - this.pageTitle = `Edit Movie: ${this.movie.title}`; - } + createForm(movie:IMovie) { + const {title, director, starRating, description} = movie; - this.titleControl = new Control(this.movie.title); + const titleControl = new Control(title, Validators.compose([ + Validators.required, + Validators.minLength(3), + Validators.maxLength(50) + ])); + this.titleMessage$ = this.createMessageStream(titleControl, { + 'required': 'Movie title is required', + 'minlength': 'Movie title must be at least three characters.', + 'maxlength': 'Movie title cannot exceed 50 characters.' + }); + + const directorControl = new Control(director, Validators.compose([ + Validators.required, + Validators.minLength(3), + Validators.maxLength(50) + ])); + this.directorMessage$ = this.createMessageStream(directorControl, { + 'required': 'Director is required', + 'minlength': 'Director must be at least 5 characters.', + 'maxlength': 'Director cannot exceed 50 characters.' + }); + + const descriptionControl = new Control(description, Validators.compose([ + Validators.required, + Validators.minLength(3) + ])); + this.descriptionMessage$ = this.createMessageStream(descriptionControl, { + 'required': 'Description is required', + 'minlength': 'Description must be at least 5 characters.' + }); + + const starRatingControl = new Control(starRating, Validators.compose([ + NumberValidator.range(1, 5), + ])); + this.starRatingMessage$ = this.createMessageStream(starRatingControl, { + 'min': 'Requires a rating greater than 0', + 'max': 'Requires a rating less than 5', + 'NaN': 'Requires a number', + }); this.editForm = this._fb.group({ - 'title': this.titleControl, - 'director': [this.movie.director], - 'starRating': [this.movie.starRating], - 'description': [this.movie.description] + 'title': titleControl, + 'director': directorControl, + 'starRating': starRatingControl, + 'description': descriptionControl }); } saveMovie() { if (this.editForm.dirty && this.editForm.valid) { - this.movie = this.editForm.value; - alert(`Movie: ${JSON.stringify(this.movie)}`); + alert(`Movie: ${JSON.stringify(this.editForm.value)}`); } } } diff --git a/MovieHunter/app/movies/movie.service.ts b/MovieHunter/app/movies/movie.service.ts index 8c8a100..1ba38a7 100644 --- a/MovieHunter/app/movies/movie.service.ts +++ b/MovieHunter/app/movies/movie.service.ts @@ -21,7 +21,8 @@ export class MovieService { return this._http.get(this._moviesUrl) .map(res => this.handleMap(res, id)) .do(data => console.log('Data: ' + JSON.stringify(data))) - .catch(this.handleError); + .catch(this.handleError) + .share(); } private handleError(error: Response) { diff --git a/MovieHunter/app/shared/number.validator.directive.ts b/MovieHunter/app/shared/number.validator.directive.ts index 453b113..679f0c1 100644 --- a/MovieHunter/app/shared/number.validator.directive.ts +++ b/MovieHunter/app/shared/number.validator.directive.ts @@ -13,9 +13,9 @@ export const RANGE_VALIDATOR = { providers: [RANGE_VALIDATOR] }) export class RangeValidator implements Validator { - private _validator; + private _validator:(c:AbstractControl)=> any; - constructor(@Attribute('range') range) { + constructor(@Attribute('range') range:string) { const [min, max] = range.split(',').map(v => parseInt(v)); this._validator = NumberValidator.range(min, max); } diff --git a/MovieHunter/app/shared/number.validator.ts b/MovieHunter/app/shared/number.validator.ts index a1f02ec..9ba62cc 100644 --- a/MovieHunter/app/shared/number.validator.ts +++ b/MovieHunter/app/shared/number.validator.ts @@ -3,7 +3,7 @@ import { Control } from '@angular/common'; export class NumberValidator { - static range(min, max){ + static range(min:number, max:number){ return (control: Control): { [key: string]: boolean } => { if (control.value < min ) { return { 'min': true };