diff --git a/#serve# b/#serve# new file mode 100644 index 000000000..c482a159a --- /dev/null +++ b/#serve# @@ -0,0 +1,5 @@ +A + + +BBBBBBBBBBBBBBasd +BBAABBBB \ No newline at end of file diff --git a/README.md b/README.md index ed221c269..a7599342f 100644 --- a/README.md +++ b/README.md @@ -2,26 +2,24 @@ This is the repository for my course **Angular Essential Training** The full course is available at [LinkedIn Learning](https://www.linkedin.com/learning) and [lynda.com](https://lynda.com). -[LinkedIn Learning subscribers: watch here](https://www.linkedin.com/learning/angular-essential-training-2) -[Lynda.com subscribers: watch here](https://www.lynda.com/Angular-tutorials/Angular-Essential-Training/5034181-2.html) +[LinkedIn Learning subscribers: watch here](https://www.linkedin.com/learning/angular-2-essential-training-2) +[Lynda.com subscribers: watch here](https://www.lynda.com/AngularJS-tutorials/Angular-2-Essential-Training/540347-2.html) ## Course Description -Angular was designed by Google to address challenges programmers face building complex, single-page applications. This JavaScript platform provides a solid core of web functionality, letting you take care of the design and implementation details. In this course, Justin Schwartzenberger introduces you to the essentials of this "superheroic" platform, including powerful features such as two-way data binding, comprehensive routing, and dependency injection. Justin steps through the platform one feature at a time, focusing on the component-based architecture of Angular. Learn what Angular is and what it can do, as Justin builds a full-featured web app from start to finish. After mastering the essentials, you can tackle the other project-based courses in our library and create your own Angular app. +JavaScript frameworks help you code more quickly, by providing special functionality for developing specific types of web projects. Angular was designed by Google to address challenges programmers face building single-page applications. This course introduces you to the essentials of this "superheroic" framework, including declarative templates, two-way data binding, and dependency injection. Justin Schwartzenberger steps through the framework one feature at a time, focusing on the new component-based architecture of Angular. After completing this training, you'll be able to tackle the other project-based courses in our library and create your own Angular app. Topics include: - What is Angular? -- Working with components -- Binding events and properties -- Getting data to components -- Using directives and pipes -- Creating Angular forms -- Validating form data -- How Angular does dependency injection -- Making HTTP calls -- Routing -- Styling components +- Setting up an Angular template +- Creating a component +- Displaying data +- Working with events +- Using two-way data binding +- Creating a subcomponent +- Using the built-in HTTP module +- Using the built-in router module ## Instructions @@ -36,10 +34,6 @@ Topics include: 3. CD to the folder `cd angular-essential-training` - - and then fetch all of the remote branches for the repository - - `git fetch --all` 4. Run the following to install the project dependencies: @@ -53,7 +47,7 @@ Topics include: `http://localhost:4200/` -The repository has a branch for each video starting point. For example, the branch **02-01b** is used as the starting code for the video *02-01 NgModule and the root module*. You can checkout branches using `git checkout ` and not have to re-run `npm install` each time since you will remain in the same root folder. +The repository has a branch for each video starting point. For example, the branch **02-01b** is used as the starting code for the video *02-01 NgModule and the root module*. You can checkout branches using `git checkout -b ` and not have to re-run `npm install` each time since you will remain in the same root folder. ## Angular CLI @@ -62,6 +56,18 @@ This project was generated with [Angular CLI](https://github.com/angular/angular To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/master/README.md). +## FAQ +If you are getting a list of errors on `npm install` that look like `Cannot find name 'Promise'`, check your `package.json` file and see if the following DevDependencies have a caret in front of the version number (the ^ symbol): +```json +"devDependencies": { + + "@types/core-js": "0.9.34", + "@types/node": "6.0.41" + +} +``` +If the caret is there (would look like `"@types/core-js": "^0.9.34"`) then remove it (or copy the contents of the [package.json](https://github.com/coursefiles/angular2-essential-training/blob/master/package.json) file on the origin repository) and run `npm install` again. + ## More Stuff Check out some of my [other courses on LinkedIn Learning](https://www.linkedin.com/learning/instructors/justin-schwartzenberger?u=2125562). You can also [follow me on twitter](https://twitter.com/schwarty). \ No newline at end of file diff --git a/src/app/app.component.css b/src/app/app.component.css new file mode 100644 index 000000000..7e7d3d759 --- /dev/null +++ b/src/app/app.component.css @@ -0,0 +1,27 @@ +:host { + display: flex; + min-height: 100%; +} +nav { + width: 68px; + background-color: #53ace4; +} +nav .icon { + width: 48px; + height: 48px; + margin: 10px; +} +section { + width: 100%; + background-color: #32435b; +} +section > header { + color: #ffffff; + padding: 10px; +} +section > header > h1 { + font-size: 2em; +} +section > header .description { + font-style: italic; +} \ No newline at end of file diff --git a/src/app/app.component.html b/src/app/app.component.html new file mode 100644 index 000000000..25177819b --- /dev/null +++ b/src/app/app.component.html @@ -0,0 +1,8 @@ +
+
+

Media Watch List

+

Keeping track of the media I want to watch.

+
+ + +
\ No newline at end of file diff --git a/src/app/app.component.ts b/src/app/app.component.ts new file mode 100644 index 000000000..542df5092 --- /dev/null +++ b/src/app/app.component.ts @@ -0,0 +1,8 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'mw-app', + templateUrl: './app.component.html', + styleUrls: ['./app.component.css'] +}) +export class AppComponent {} diff --git a/src/app/app.module.ts b/src/app/app.module.ts new file mode 100644 index 000000000..034f93658 --- /dev/null +++ b/src/app/app.module.ts @@ -0,0 +1,36 @@ +import { NgModule } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; +import { ReactiveFormsModule } from '@angular/forms'; +import { HttpClientModule, HttpXhrBackend } from '@angular/common/http'; +import { AppComponent } from './app.component'; +import { MediaItemComponent } from './media-item.component'; +import { MediaItemListComponent } from './media-item-list.component'; +import { FavoriteDirective } from './favorite.directive'; +import { CategoryListPipe } from './category-list.pipe'; +import { MediaItemFormComponent } from './media-item-form.component'; +import { lookupListToken, lookupLists } from './providers'; +import { MockXHRBackend } from './mock-xhr-backend'; + +@NgModule({ + imports: [ + BrowserModule, + ReactiveFormsModule, + HttpClientModule + ], + declarations: [ + AppComponent, + MediaItemComponent, + MediaItemListComponent, + FavoriteDirective, + CategoryListPipe, + MediaItemFormComponent + ], + providers: [ + { provide: lookupListToken, useValue: lookupLists }, + { provide: HttpXhrBackend, useClass: MockXHRBackend } + ], + bootstrap: [ + AppComponent + ] +}) +export class AppModule {} diff --git a/src/app/category-list.pipe.ts b/src/app/category-list.pipe.ts new file mode 100644 index 000000000..7e03e0237 --- /dev/null +++ b/src/app/category-list.pipe.ts @@ -0,0 +1,16 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'categoryList' +}) +export class CategoryListPipe implements PipeTransform { + transform(mediaItems) { + const categories = []; + mediaItems.forEach(mediaItem => { + if (categories.indexOf(mediaItem.category) <= -1) { + categories.push(mediaItem.category); + } + }); + return categories.join(', '); + } +} diff --git a/src/app/favorite.directive.ts b/src/app/favorite.directive.ts new file mode 100644 index 000000000..33fd4d424 --- /dev/null +++ b/src/app/favorite.directive.ts @@ -0,0 +1,22 @@ +import { Directive, HostBinding, HostListener, Input } from '@angular/core'; + +@Directive({ + selector: '[mwFavorite]' +}) +export class FavoriteDirective { + @HostBinding('class.is-favorite') isFavorite = true; + + @HostBinding('class.is-favorite-hovering') hovering = false; + + @HostListener('mouseenter') onMouseEnter() { + this.hovering = true; + } + + @HostListener('mouseleave') onMouseLeave() { + this.hovering = false; + } + + @Input() set mwFavorite(value) { + this.isFavorite = value; + } +} diff --git a/src/app/media-item-form.component.css b/src/app/media-item-form.component.css new file mode 100644 index 000000000..15b57f9ef --- /dev/null +++ b/src/app/media-item-form.component.css @@ -0,0 +1,52 @@ +:host { + display: block; + padding: 10px; +} +ul { + list-style-type: none; +} +ul li { + margin: 10px 0; +} +header, label { + color: #53ace4; +} +input, select { + background-color: #29394b; + color: #c6c5c3; + border-radius: 3px; + border: none; + box-shadow: 0 1px 2px rgba(0,0,0,0.2) inset, 0 -1px 0 rgba(0,0,0,0.05) inset; + border-color: #53ace4; + padding: 6px; +} +.ng-invalid:not(.ng-pristine):not(.required-invalid) { + border: 1px solid #d93a3e; +} +input[required].ng-invalid { + border-right: 5px solid #d93a3e; +} +input[required]:not(.required-invalid), +input[required].ng-invalid:not(.required-invalid) { + border-right: 5px solid #37ad79; +} +.error { + color: #d93a3e; +} +#year { + width: 50px; +} +button[type=submit] { + background-color: #45bf94; + border: 0; + padding: 10px; + font-size: 1em; + border-radius: 4px; + color: #ffffff; + cursor: pointer; +} +button[type=submit]:disabled { + background-color: #333; + color: #666; + cursor: default; +} \ No newline at end of file diff --git a/src/app/media-item-form.component.html b/src/app/media-item-form.component.html new file mode 100644 index 000000000..4d169642b --- /dev/null +++ b/src/app/media-item-form.component.html @@ -0,0 +1,44 @@ +
+

Add Media to Watch

+
+
+
    +
  • + + +
  • +
  • + + +
    + Name has invalid characters +
    +
  • +
  • + + +
  • +
  • + + +
    + Must be between + {{yearErrors.year.min}} + and + {{yearErrors.year.max}} +
    +
  • +
+ +
\ No newline at end of file diff --git a/src/app/media-item-form.component.ts b/src/app/media-item-form.component.ts new file mode 100644 index 000000000..6579159f4 --- /dev/null +++ b/src/app/media-item-form.component.ts @@ -0,0 +1,54 @@ +import { Component, OnInit, Inject } from '@angular/core'; +import { FormGroup, FormControl, Validators, FormBuilder } from '@angular/forms'; +import { MediaItemService } from './media-item.service'; +import { lookupListToken } from './providers'; + +@Component({ + selector: 'mw-media-item-form', + templateUrl: './media-item-form.component.html', + styleUrls: ['./media-item-form.component.css'] +}) +export class MediaItemFormComponent implements OnInit { + form: FormGroup; + + constructor( + private formBuilder: FormBuilder, + private mediaItemService: MediaItemService, + @Inject(lookupListToken) public lookupLists) {} + + ngOnInit() { + this.form = this.formBuilder.group({ + medium: this.formBuilder.control('Movies'), + name: this.formBuilder.control('', Validators.compose([ + Validators.required, + Validators.pattern('[\\w\\-\\s\\/]+') + ])), + category: this.formBuilder.control(''), + year: this.formBuilder.control('', this.yearValidator), + }); + } + + yearValidator(control: FormControl) { + if (control.value.trim().length === 0) { + return null; + } + const year = parseInt(control.value, 10); + const minYear = 1900; + const maxYear = 2100; + if (year >= minYear && year <= maxYear) { + return null; + } else { + return { + year: { + min: minYear, + max: maxYear + } + }; + } + } + + onSubmit(mediaItem) { + this.mediaItemService.add(mediaItem) + .subscribe(); + } +} diff --git a/src/app/media-item-list.component.css b/src/app/media-item-list.component.css new file mode 100644 index 000000000..047f6b215 --- /dev/null +++ b/src/app/media-item-list.component.css @@ -0,0 +1,40 @@ +:host { + display: flex; + height: calc(100% - 95px); + flex-direction: column; + padding: 10px; +} +nav a { + cursor: pointer; +} +header { + color: #c6c5c3; +} +header.medium-movies { + color: #53ace4; +} +header.medium-series { + color: #45bf94; +} +header > h2 { + font-size: 1.4em; +} +header > h2.error { + color: #d93a3e; +} +section { + flex: 1; + display: flex; + flex-flow: row wrap; + align-content: flex-start; +} +section > media-item { + margin: 10px; +} +footer { + text-align: right; +} +footer .icon { + width: 64px; + height: 64px; +} \ No newline at end of file diff --git a/src/app/media-item-list.component.html b/src/app/media-item-list.component.html new file mode 100644 index 000000000..fa6b17984 --- /dev/null +++ b/src/app/media-item-list.component.html @@ -0,0 +1,23 @@ + +
+

{{medium}}

+
{{ mediaItems | categoryList }}
+
+
+ +
\ No newline at end of file diff --git a/src/app/media-item-list.component.ts b/src/app/media-item-list.component.ts new file mode 100644 index 000000000..4e5c85e5a --- /dev/null +++ b/src/app/media-item-list.component.ts @@ -0,0 +1,33 @@ +import { Component, OnInit } from '@angular/core'; +import { MediaItemService, MediaItem } from './media-item.service'; + +@Component({ + selector: 'mw-media-item-list', + templateUrl: './media-item-list.component.html', + styleUrls: ['./media-item-list.component.css'] +}) +export class MediaItemListComponent implements OnInit { + medium = ''; + mediaItems: MediaItem[]; + + constructor(private mediaItemService: MediaItemService) {} + + ngOnInit() { + this.getMediaItems(this.medium); + } + + onMediaItemDelete(mediaItem: MediaItem) { + this.mediaItemService.delete(mediaItem) + .subscribe(() => { + this.getMediaItems(this.medium); + }); + } + + getMediaItems(medium: string) { + this.medium = medium; + this.mediaItemService.get(medium) + .subscribe(mediaItems => { + this.mediaItems = mediaItems; + }); + } +} diff --git a/src/app/media-item.component.css b/src/app/media-item.component.css new file mode 100644 index 000000000..d3da443aa --- /dev/null +++ b/src/app/media-item.component.css @@ -0,0 +1,66 @@ +:host { + display: flex; + flex-direction: column; + width: 140px; + height: 200px; + border: 2px solid; + background-color: #29394b; + padding: 10px; + color: #bdc2c5; + margin: 0 12px 12px 0; +} +h2 { + font-size: 1.6em; + flex: 1; +} +:host(.medium-movies) { + border-color: #53ace4; +} +:host(.medium-movies) > h2 { + color: #53ace4; +} +:host(.medium-series) { + border-color: #45bf94; +} +:host(.medium-series) > h2 { + color: #45bf94; +} +.tools { + margin-top: 8px; + display: flex; + flex-wrap: nowrap; + justify-content: space-between; +} +.favorite { + width: 24px; + height: 24px; + fill: #bdc2c5; + cursor: pointer; +} +.favorite.is-favorite { + fill: #37ad79; +} +.favorite.is-favorite-hovering { + fill: #45bf94; +} +.favorite.is-favorite.is-favorite-hovering { + fill: #ec4342; +} +.delete { + display: block; + background-color: #ec4342; + padding: 4px; + font-size: .8em; + border-radius: 4px; + color: #ffffff; + cursor: pointer; +} +.details { + display: block; + background-color: #37ad79; + padding: 4px; + font-size: .8em; + border-radius: 4px; + color: #ffffff; + text-decoration: none; +} \ No newline at end of file diff --git a/src/app/media-item.component.html b/src/app/media-item.component.html new file mode 100644 index 000000000..38ceb2b5a --- /dev/null +++ b/src/app/media-item.component.html @@ -0,0 +1,20 @@ +

{{ mediaItem.name }}

+ +
Watched on {{ mediaItem.watchedOn | date: 'shortDate' }}
+
+
{{ mediaItem.category }}
+
{{ mediaItem.year }}
+ \ No newline at end of file diff --git a/src/app/media-item.component.ts b/src/app/media-item.component.ts new file mode 100644 index 000000000..22ce942b4 --- /dev/null +++ b/src/app/media-item.component.ts @@ -0,0 +1,15 @@ +import { Component, Input, Output, EventEmitter } from '@angular/core'; + +@Component({ + selector: 'mw-media-item', + templateUrl: './media-item.component.html', + styleUrls: ['./media-item.component.css'] +}) +export class MediaItemComponent { + @Input() mediaItem; + @Output() delete = new EventEmitter(); + + onDelete() { + this.delete.emit(this.mediaItem); + } +} diff --git a/src/app/media-item.service.ts b/src/app/media-item.service.ts new file mode 100644 index 000000000..207b1a19f --- /dev/null +++ b/src/app/media-item.service.ts @@ -0,0 +1,53 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { map, catchError } from 'rxjs/operators'; +import { throwError } from 'rxjs'; + +@Injectable({ + providedIn: 'root' +}) +export class MediaItemService { + constructor(private http: HttpClient) { } + + get(medium: string) { + const getOptions = { + params: { medium } + }; + return this.http.get('mediaitems', getOptions) + .pipe( + map((response: MediaItemsResponse) => { + return response.mediaItems; + }), + catchError(this.handleError) + ); + } + + add(mediaItem: MediaItem) { + return this.http.post('mediaitems', mediaItem) + .pipe(catchError(this.handleError)); + } + + delete(mediaItem: MediaItem) { + return this.http.delete(`mediaitems/${mediaItem.id}`) + .pipe(catchError(this.handleError)); + } + + private handleError(error: HttpErrorResponse) { + console.log(error.message); + return throwError('A data error occurred!'); + } +} + +interface MediaItemsResponse { + mediaItems: MediaItem[]; +} + +export interface MediaItem { + id: number; + name: string; + medium: string; + category: string; + year: number; + watchedOn: number; + isFavorite: boolean; +} diff --git a/src/app/mock-xhr-backend.ts b/src/app/mock-xhr-backend.ts new file mode 100644 index 000000000..c7ebd54a2 --- /dev/null +++ b/src/app/mock-xhr-backend.ts @@ -0,0 +1,116 @@ +import { HttpEvent, HttpRequest, HttpResponse, HttpBackend } from '@angular/common/http'; +import { Observable, Observer } from 'rxjs'; + +export class MockXHRBackend implements HttpBackend { + private mediaItems = [ + { + id: 1, + name: 'Firebug', + medium: 'Series', + category: 'Science Fiction', + year: 2010, + watchedOn: 1294166565384, + isFavorite: false + }, + { + id: 2, + name: 'The Small Tall', + medium: 'Movies', + category: 'Comedy', + year: 2015, + watchedOn: null, + isFavorite: true + }, { + id: 3, + name: 'The Redemption', + medium: 'Movies', + category: 'Action', + year: 2016, + watchedOn: null, + isFavorite: false + }, { + id: 4, + name: 'Hoopers', + medium: 'Series', + category: 'Drama', + year: null, + watchedOn: null, + isFavorite: true + }, { + id: 5, + name: 'Happy Joe: Cheery Road', + medium: 'Movies', + category: 'Action', + year: 2015, + watchedOn: 1457166565384, + isFavorite: false + } + ]; + + handle(request: HttpRequest): Observable> { + return new Observable((responseObserver: Observer>) => { + let responseOptions; + switch (request.method) { + case 'GET': + if (request.urlWithParams.indexOf('mediaitems?medium=') >= 0 || request.url === 'mediaitems') { + let medium; + if (request.urlWithParams.indexOf('?') >= 0) { + medium = request.urlWithParams.split('=')[1]; + if (medium === 'undefined') { medium = ''; } + } + let mediaItems; + if (medium) { + mediaItems = this.mediaItems.filter(i => i.medium === medium); + } else { + mediaItems = this.mediaItems; + } + responseOptions = { + body: {mediaItems: JSON.parse(JSON.stringify(mediaItems))}, + status: 200 + }; + } else { + let mediaItems; + const idToFind = parseInt(request.url.split('/')[1], 10); + mediaItems = this.mediaItems.filter(i => i.id === idToFind); + responseOptions = { + body: JSON.parse(JSON.stringify(mediaItems[0])), + status: 200 + }; + } + break; + case 'POST': + const mediaItem = request.body; + mediaItem.id = this._getNewId(); + this.mediaItems.push(mediaItem); + responseOptions = {status: 201}; + break; + case 'DELETE': + const id = parseInt(request.url.split('/')[1], 10); + this._deleteMediaItem(id); + responseOptions = {status: 200}; + } + + const responseObject = new HttpResponse(responseOptions); + responseObserver.next(responseObject); + responseObserver.complete(); + return () => { + }; + }); + } + + _deleteMediaItem(id) { + const mediaItem = this.mediaItems.find(i => i.id === id); + const index = this.mediaItems.indexOf(mediaItem); + if (index >= 0) { + this.mediaItems.splice(index, 1); + } + } + + _getNewId() { + if (this.mediaItems.length > 0) { + return Math.max.apply(Math, this.mediaItems.map(mediaItem => mediaItem.id)) + 1; + } else { + return 1; + } + } +} diff --git a/src/app/providers.ts b/src/app/providers.ts new file mode 100644 index 000000000..2dd00660b --- /dev/null +++ b/src/app/providers.ts @@ -0,0 +1,7 @@ +import { InjectionToken } from '@angular/core'; + +export const lookupListToken = new InjectionToken('lookupListToken'); + +export const lookupLists = { + mediums: ['Movies', 'Series'] +}; diff --git a/src/index.html b/src/index.html index 01c08e3c4..da9d46ee2 100644 --- a/src/index.html +++ b/src/index.html @@ -9,6 +9,6 @@ - + diff --git a/src/main.ts b/src/main.ts index e69de29bb..f22933ba8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -0,0 +1,4 @@ +import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; +import { AppModule } from './app/app.module'; + +platformBrowserDynamic().bootstrapModule(AppModule); diff --git a/tsconfig.json b/tsconfig.json index 6ec9ceb17..856a8c193 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,4 +19,4 @@ "dom" ] } -} +} \ No newline at end of file