diff --git a/db.json b/db.json index f417cc3..bcb6d13 100644 --- a/db.json +++ b/db.json @@ -3,12 +3,15 @@ { "id": "1", "name": "Interstellar", - "rating": 4.5 + "description": "Space", + "earnings": 25000000 }, { "id": "2", "name": "Inception", - "rating": 1.0 + "description": "I forgot", + "earnings": 50000000 } - ] -} + ], + "books": [] +} \ No newline at end of file diff --git a/package.json b/package.json index cf0a99b..8cdc2ec 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,8 @@ "version": "0.0.0", "scripts": { "ng": "ng", - "start": "ng serve", + "start": "concurrently \"npm run server\" \"ng serve\"", + "server": "json-server db.json --watch", "build": "ng build", "test": "jest", "lint": "ng lint", @@ -18,16 +19,20 @@ "private": true, "dependencies": { "@angular/animations": "~7.2.0", + "@angular/cdk": "~7.3.0", "@angular/common": "~7.2.0", "@angular/compiler": "~7.2.0", "@angular/core": "~7.2.0", "@angular/forms": "~7.2.0", + "@angular/material": "~7.3.0", "@angular/platform-browser": "~7.2.0", "@angular/platform-browser-dynamic": "~7.2.0", "@angular/router": "~7.2.0", "@ngrx/effects": "^7.4.0", "@ngrx/entity": "^7.4.0", + "@ngrx/router-store": "^7.4.0", "@ngrx/store": "^7.4.0", + "@ngrx/store-devtools": "^7.4.0", "core-js": "^2.5.4", "rxjs": "~6.3.3", "tslib": "^1.9.0", diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts deleted file mode 100644 index d425c6f..0000000 --- a/src/app/app-routing.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { NgModule } from '@angular/core'; -import { Routes, RouterModule } from '@angular/router'; - -const routes: Routes = []; - -@NgModule({ - imports: [RouterModule.forRoot(routes)], - exports: [RouterModule] -}) -export class AppRoutingModule { } diff --git a/src/app/app.component.css b/src/app/app.component.css new file mode 100644 index 0000000..51e9768 --- /dev/null +++ b/src/app/app.component.css @@ -0,0 +1,32 @@ +:host { + display: flex; + flex-direction: column; + flex: 1; +} + +.nav-link { + color: rgba(0,0,0,.54); + display: flex; + align-items:center; + padding-top: 5px; + padding-bottom: 5px; +} + +mat-toolbar { + box-shadow: 0 3px 5px -1px rgba(0,0,0,.2), 0 6px 10px 0 rgba(0,0,0,.14), 0 1px 18px 0 rgba(0,0,0,.12); + z-index: 1; +} + +mat-toolbar > .mat-mini-fab { + margin-right: 10px; +} + +mat-sidenav { + box-shadow: 3px 0 6px rgba(0,0,0,.24); + width: 200px; +} + +.mat-sidenav-container { + background: #f5f5f5; + flex: 1; +} diff --git a/src/app/app.component.html b/src/app/app.component.html new file mode 100644 index 0000000..f1cc9be --- /dev/null +++ b/src/app/app.component.html @@ -0,0 +1,25 @@ + + + + {{ title }} + + + + + + + + +
+ +
+
diff --git a/src/app/app.component.ts b/src/app/app.component.ts index d95da2e..ce23167 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,11 +1,14 @@ import { Component } from "@angular/core"; @Component({ - selector: "ngrx-root", - template: ` -

NgRx Workshop Example

- - `, - styles: [] + selector: "app-root", + templateUrl: './app.component.html', + styleUrls: ['./app.component.css'] }) -export class AppComponent {} +export class AppComponent { + title = 'NgRx Workshop'; + links = [ + { path: '/movies', icon: 'movie', label: 'Movies'}, + { path: '/books', icon: 'book', label: 'Books'} + ]; +} diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 7883622..0f8a346 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -1,23 +1,37 @@ import { BrowserModule } from "@angular/platform-browser"; +import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; import { NgModule } from "@angular/core"; +import { RouterModule } from '@angular/router'; +import { HttpClientModule } from '@angular/common/http'; -import { AppRoutingModule } from "./app-routing.module"; -import { AppComponent } from "./app.component"; import { StoreModule } from "@ngrx/store"; -import { reducers, metaReducers } from "./shared/state"; +import { StoreDevtoolsModule } from "@ngrx/store-devtools"; import { EffectsModule } from "@ngrx/effects"; + +import { MaterialModule } from './material.module'; import { MoviesModule } from "./movies/movies.module"; +import { AppComponent } from "./app.component"; + +import { reducers, metaReducers } from "./shared/state"; +import { BooksModule } from './books/books.module'; + @NgModule({ declarations: [AppComponent], imports: [ BrowserModule, - AppRoutingModule, + BrowserAnimationsModule, + HttpClientModule, + RouterModule.forRoot([ + { path: '', pathMatch: 'full', redirectTo: '/movies' } + ]), StoreModule.forRoot(reducers, { metaReducers }), + StoreDevtoolsModule.instrument(), EffectsModule.forRoot([]), - MoviesModule + MaterialModule, + MoviesModule, + BooksModule ], - providers: [], bootstrap: [AppComponent] }) export class AppModule {} diff --git a/src/app/books/actions/books-api.actions.ts b/src/app/books/actions/books-api.actions.ts new file mode 100644 index 0000000..5326615 --- /dev/null +++ b/src/app/books/actions/books-api.actions.ts @@ -0,0 +1,2 @@ +import { Book } from "src/app/shared/models/book.model"; +import { Action } from "@ngrx/store"; diff --git a/src/app/books/actions/books-page.actions.ts b/src/app/books/actions/books-page.actions.ts new file mode 100644 index 0000000..5326615 --- /dev/null +++ b/src/app/books/actions/books-page.actions.ts @@ -0,0 +1,2 @@ +import { Book } from "src/app/shared/models/book.model"; +import { Action } from "@ngrx/store"; diff --git a/src/app/books/actions/index.ts b/src/app/books/actions/index.ts new file mode 100644 index 0000000..d7820c5 --- /dev/null +++ b/src/app/books/actions/index.ts @@ -0,0 +1,4 @@ +import * as BooksPageActions from './books-page.actions'; +import * as BooksApiActions from './books-api.actions'; + +export { BooksPageActions, BooksApiActions }; \ No newline at end of file diff --git a/src/app/books/books.module.ts b/src/app/books/books.module.ts new file mode 100644 index 0000000..2239fb8 --- /dev/null +++ b/src/app/books/books.module.ts @@ -0,0 +1,29 @@ +import { NgModule } from "@angular/core"; +import { CommonModule } from '@angular/common'; +import { RouterModule } from '@angular/router'; +import { ReactiveFormsModule } from '@angular/forms'; + +import { MaterialModule } from 'src/app/material.module'; + +import { BooksPageComponent } from './components/books-page/books-page.component'; +import { BookDetailComponent } from './components/book-detail/book-detail.component'; +import { BooksListComponent } from './components/books-list/books-list.component'; +import { BooksTotalComponent } from './components/books-total/books-total.component'; + +@NgModule({ + imports: [ + CommonModule, + ReactiveFormsModule, + MaterialModule, + RouterModule.forChild([ + { path: 'books', component: BooksPageComponent } + ]), + ], + declarations: [ + BooksPageComponent, + BookDetailComponent, + BooksListComponent, + BooksTotalComponent + ] +}) +export class BooksModule {} diff --git a/src/app/books/components/book-detail/book-detail.component.css b/src/app/books/components/book-detail/book-detail.component.css new file mode 100755 index 0000000..39d7397 --- /dev/null +++ b/src/app/books/components/book-detail/book-detail.component.css @@ -0,0 +1,9 @@ +mat-card-actions { + margin-bottom: 0; +} +mat-card-header { + margin-bottom: 10px; +} +.full-width { + width: 100%; +} diff --git a/src/app/books/components/book-detail/book-detail.component.html b/src/app/books/components/book-detail/book-detail.component.html new file mode 100755 index 0000000..fc1b3ef --- /dev/null +++ b/src/app/books/components/book-detail/book-detail.component.html @@ -0,0 +1,27 @@ + + + +

+ Editing {{originalBook.name}} + Create Book +

+
+
+
+ + + + + + + + + + + + + + + +
+
diff --git a/src/app/books/components/book-detail/book-detail.component.spec.ts b/src/app/books/components/book-detail/book-detail.component.spec.ts new file mode 100755 index 0000000..2a5643e --- /dev/null +++ b/src/app/books/components/book-detail/book-detail.component.spec.ts @@ -0,0 +1,11 @@ +/* tslint:disable:no-unused-variable */ + +import { TestBed, async } from '@angular/core/testing'; +import { BookDetailComponent } from './book-detail.component'; + +describe('Component: BookDetail', () => { + it('should create an instance', () => { + const component = new BookDetailComponent(); + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/books/components/book-detail/book-detail.component.ts b/src/app/books/components/book-detail/book-detail.component.ts new file mode 100755 index 0000000..9a05cf5 --- /dev/null +++ b/src/app/books/components/book-detail/book-detail.component.ts @@ -0,0 +1,39 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { Book } from 'src/app/shared/models/book.model'; +import { FormGroup, FormControl } from '@angular/forms'; + +@Component({ + selector: 'app-book-detail', + templateUrl: './book-detail.component.html', + styleUrls: ['./book-detail.component.css'] +}) +export class BookDetailComponent { + originalBook: Book | undefined; + @Output() save = new EventEmitter(); + @Output() cancel = new EventEmitter(); + + bookForm = new FormGroup({ + name: new FormControl(''), + earnings: new FormControl(0), + description: new FormControl('') + }); + + @Input() set book(book: Book) { + this.bookForm.reset(); + this.originalBook = null; + + if (book) { + this.bookForm.setValue({ + name: book.name, + earnings: book.earnings, + description: book.description + }); + + this.originalBook = book; + } + } + + onSubmit(book: Book) { + this.save.emit({ ...this.originalBook, ...book }); + } +} diff --git a/src/app/books/components/books-list/books-list.component.css b/src/app/books/components/books-list/books-list.component.css new file mode 100755 index 0000000..1ffd996 --- /dev/null +++ b/src/app/books/components/books-list/books-list.component.css @@ -0,0 +1,7 @@ +mat-list-item:not(:first-of-type) { + border-top: 1px solid #efefef; +} + +.symbol { + color: #777; +} diff --git a/src/app/books/components/books-list/books-list.component.html b/src/app/books/components/books-list/books-list.component.html new file mode 100755 index 0000000..bb36697 --- /dev/null +++ b/src/app/books/components/books-list/books-list.component.html @@ -0,0 +1,23 @@ + + + +

Books

+
+
+ + + +

{{book.name}}

+

+ {{book.description}} +

+

+ {{book.earnings | currency}} +

+ +
+
+
+
diff --git a/src/app/books/components/books-list/books-list.component.spec.ts b/src/app/books/components/books-list/books-list.component.spec.ts new file mode 100755 index 0000000..3dd9ce0 --- /dev/null +++ b/src/app/books/components/books-list/books-list.component.spec.ts @@ -0,0 +1,11 @@ +/* tslint:disable:no-unused-variable */ + +import { TestBed, async } from '@angular/core/testing'; +import { BooksListComponent } from './books-list.component'; + +describe('Component: BooksList', () => { + it('should create an instance', () => { + const component = new BooksListComponent(); + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/books/components/books-list/books-list.component.ts b/src/app/books/components/books-list/books-list.component.ts new file mode 100755 index 0000000..838a298 --- /dev/null +++ b/src/app/books/components/books-list/books-list.component.ts @@ -0,0 +1,14 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { Book } from 'src/app/shared/models/book.model'; + +@Component({ + selector: 'app-books-list', + templateUrl: './books-list.component.html', + styleUrls: ['./books-list.component.css'] +}) +export class BooksListComponent { + @Input() books: Book[]; + @Input() readonly = false; + @Output() select = new EventEmitter(); + @Output() delete = new EventEmitter(); +} diff --git a/src/app/books/components/books-page/books-page.component.css b/src/app/books/components/books-page/books-page.component.css new file mode 100755 index 0000000..40d8251 --- /dev/null +++ b/src/app/books/components/books-page/books-page.component.css @@ -0,0 +1,4 @@ +:host >>> mat-list-item:hover { + cursor: pointer; + background: whitesmoke; +} diff --git a/src/app/books/components/books-page/books-page.component.html b/src/app/books/components/books-page/books-page.component.html new file mode 100755 index 0000000..096bcf3 --- /dev/null +++ b/src/app/books/components/books-page/books-page.component.html @@ -0,0 +1,19 @@ +
+
+ + + + + +
+ + + +
diff --git a/src/app/books/components/books-page/books-page.component.spec.ts b/src/app/books/components/books-page/books-page.component.spec.ts new file mode 100755 index 0000000..4fbccc4 --- /dev/null +++ b/src/app/books/components/books-page/books-page.component.spec.ts @@ -0,0 +1,49 @@ +/* tslint:disable:no-unused-variable */ + +import { TestBed, async, ComponentFixture } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { ReactiveFormsModule } from '@angular/forms'; + +import { MaterialModule } from 'src/app/material.module'; + +import { BooksPageComponent } from './books-page.component'; +import { BooksService } from 'src/app/shared/services/book.service'; +import { BooksListComponent } from '../books-list/books-list.component'; +import { BookDetailComponent } from '../book-detail/book-detail.component'; +import { BooksTotalComponent } from '../books-total/books-total.component'; +import { provideMockStore } from '@ngrx/store/testing'; + +class BooksServiceStub {} + +describe('Component: Books Page', () => { + let comp: BooksPageComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + MaterialModule, + NoopAnimationsModule, + ReactiveFormsModule + ], + declarations: [ + BooksPageComponent, + BooksPageComponent, + BooksListComponent, + BookDetailComponent, + BooksTotalComponent + ], + providers: [ + { provide: BooksService, useClass: BooksServiceStub }, + provideMockStore() + ] + }); + + fixture = TestBed.createComponent(BooksPageComponent); + comp = fixture.componentInstance; + }); + + it('should create an instance', () => { + expect(comp).toBeTruthy(); + }); +}); diff --git a/src/app/books/components/books-page/books-page.component.ts b/src/app/books/components/books-page/books-page.component.ts new file mode 100755 index 0000000..634ef32 --- /dev/null +++ b/src/app/books/components/books-page/books-page.component.ts @@ -0,0 +1,80 @@ +import { Component, OnInit } from '@angular/core'; + +import { Book } from 'src/app/shared/models/book.model'; +import { BooksService } from 'src/app/shared/services/book.service'; + +@Component({ + selector: 'app-books', + templateUrl: './books-page.component.html', + styleUrls: ['./books-page.component.css'] +}) +export class BooksPageComponent implements OnInit { + books: Book[]; + currentBook: Book; + total: number; + + constructor(private booksService: BooksService) {} + + ngOnInit() { + this.getBooks(); + this.removeSelectedBook(); + } + + getBooks() { + this.booksService.all() + .subscribe(books => { + this.books = books; + this.updateTotals(books); + }); + } + + updateTotals(books: Book[]) { + this.total = books.reduce((total, book) => { + return total + parseInt(`${book.earnings}`, 10) || 0; + }, 0); + } + + onSelect(book: Book) { + this.currentBook = book; + } + + onCancel() { + this.removeSelectedBook(); + } + + removeSelectedBook() { + this.currentBook = null; + } + + onSave(book: Book) { + if (!book.id) { + this.saveBook(book); + } else { + this.updateBook(book); + } + } + + saveBook(book: Book) { + this.booksService.create(book) + .subscribe(() => { + this.getBooks(); + this.removeSelectedBook(); + }); + } + + updateBook(book: Book) { + this.booksService.update(book.id, book) + .subscribe(() => { + this.getBooks(); + this.removeSelectedBook(); + }); + } + + onDelete(book: Book) { + this.booksService.delete(book.id) + .subscribe(() => { + this.getBooks(); + this.removeSelectedBook(); + }); + } +} diff --git a/src/app/books/components/books-total/books-total.component.css b/src/app/books/components/books-total/books-total.component.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/books/components/books-total/books-total.component.html b/src/app/books/components/books-total/books-total.component.html new file mode 100644 index 0000000..5dc9241 --- /dev/null +++ b/src/app/books/components/books-total/books-total.component.html @@ -0,0 +1,11 @@ + + + +

Books Gross Total

+
+
+ + {{ total | currency }} + +
+ diff --git a/src/app/books/components/books-total/books-total.component.spec.ts b/src/app/books/components/books-total/books-total.component.spec.ts new file mode 100644 index 0000000..632ef91 --- /dev/null +++ b/src/app/books/components/books-total/books-total.component.spec.ts @@ -0,0 +1,31 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { BooksTotalComponent } from './books-total.component'; +import { MaterialModule } from 'src/app/material.module'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +describe('BooksTotalComponent', () => { + let component: BooksTotalComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + MaterialModule, + NoopAnimationsModule + ], + declarations: [ BooksTotalComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(BooksTotalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/books/components/books-total/books-total.component.ts b/src/app/books/components/books-total/books-total.component.ts new file mode 100644 index 0000000..2785122 --- /dev/null +++ b/src/app/books/components/books-total/books-total.component.ts @@ -0,0 +1,10 @@ +import { Component, Input } from '@angular/core'; + +@Component({ + selector: 'app-books-total', + templateUrl: './books-total.component.html', + styleUrls: ['./books-total.component.css'] +}) +export class BooksTotalComponent { + @Input() total: number; +} diff --git a/src/app/material.module.ts b/src/app/material.module.ts new file mode 100755 index 0000000..2e2c14b --- /dev/null +++ b/src/app/material.module.ts @@ -0,0 +1,36 @@ +import { NgModule } from '@angular/core'; +import { + MatButtonModule, + MatCardModule, + MatCheckboxModule, + MatIconModule, + MatInputModule, + MatListModule, + MatSidenavModule, + MatToolbarModule, +} from '@angular/material'; + +@NgModule({ + imports: [ + MatButtonModule, + MatCardModule, + MatCheckboxModule, + MatIconModule, + MatInputModule, + MatListModule, + MatSidenavModule, + MatToolbarModule + ], + exports: [ + MatButtonModule, + MatCardModule, + MatCheckboxModule, + MatIconModule, + MatInputModule, + MatListModule, + MatSidenavModule, + MatToolbarModule + ] +}) +export class MaterialModule { +} diff --git a/src/app/movies/actions/index.ts b/src/app/movies/actions/index.ts index 2d02ef7..d47be00 100644 --- a/src/app/movies/actions/index.ts +++ b/src/app/movies/actions/index.ts @@ -1,4 +1,4 @@ import * as MovieApiActions from "./movie-api.actions"; -import * as MoviePageActions from "./movie-page.actions"; +import * as MoviesPageActions from "./movies-page.actions"; -export { MovieApiActions, MoviePageActions }; +export { MovieApiActions, MoviesPageActions }; diff --git a/src/app/movies/actions/movie-api.actions.ts b/src/app/movies/actions/movie-api.actions.ts index cc1eb28..e1436c1 100644 --- a/src/app/movies/actions/movie-api.actions.ts +++ b/src/app/movies/actions/movie-api.actions.ts @@ -2,40 +2,40 @@ import { createAction, props } from "@ngrx/store"; import { Movie } from "src/app/shared/models/movie.model"; export const loadMoviesSuccess = createAction( - "[Movie API] Load Movies Success", + "[Movies API] Load Movies Success", props<{ movies: Movie[] }>() ); export const loadMoviesFailure = createAction( - "[Movie API] Load Movies Failure" + "[Movies API] Load Movies Failure" ); export const createMovieSuccess = createAction( - "[Movie API] Create Movie Success", + "[Movies API] Create Movie Success", props<{ movie: Movie }>() ); export const createMovieFailure = createAction( - "[Movie API] Create Movie Failure" + "[Movies API] Create Movie Failure" ); export const updateMovieSuccess = createAction( - "[Movie API] Update Movie Success", + "[Movies API] Update Movie Success", props<{ movie: Movie }>() ); export const updateMovieFailure = createAction( - "[Movie API] Update Movie Failure", + "[Movies API] Update Movie Failure", props<{ movie: Movie }>() ); export const deleteMovieSuccess = createAction( - "[Movie API] Delete Movie Success", + "[Movies API] Delete Movie Success", props<{ movieId: string }>() ); export const deleteMovieFailure = createAction( - "[Movie API] Delete Movie Failure", + "[Movies API] Delete Movie Failure", props<{ movie: Movie }>() ); diff --git a/src/app/movies/actions/movie-page.actions.ts b/src/app/movies/actions/movies-page.actions.ts similarity index 66% rename from src/app/movies/actions/movie-page.actions.ts rename to src/app/movies/actions/movies-page.actions.ts index fec3d83..24a50ea 100644 --- a/src/app/movies/actions/movie-page.actions.ts +++ b/src/app/movies/actions/movies-page.actions.ts @@ -1,31 +1,36 @@ import { createAction, props } from "@ngrx/store"; import { MovieRequiredProps, Movie } from "src/app/shared/models/movie.model"; -export const enter = createAction("[Movie Page] Enter"); +export const enter = createAction("[Movies Page] Enter"); export const selectMovie = createAction( - "[Movie Page] Select Movie", + "[Movies Page] Select Movie", props<{ movieId: string }>() ); +export const clearSelectedMovie = createAction( + "[Movies Page] Clear Selected Movie" +); + export const createMovie = createAction( - "[Movie Page] Create Movie", + "[Movies Page] Create Movie", props<{ movie: MovieRequiredProps }>() ); export const updateMovie = createAction( - "[Movie Page] Update Movie", + "[Movies Page] Update Movie", props<{ movie: Movie; changes: MovieRequiredProps }>() ); export const deleteMovie = createAction( - "[Movie Page] Delete Movie", + "[Movies Page] Delete Movie", props<{ movie: Movie }>() ); export type Union = ReturnType< | typeof enter | typeof selectMovie + | typeof clearSelectedMovie | typeof createMovie | typeof updateMovie | typeof deleteMovie diff --git a/src/app/movies/components/movie-detail/movie-detail.component.css b/src/app/movies/components/movie-detail/movie-detail.component.css new file mode 100755 index 0000000..39d7397 --- /dev/null +++ b/src/app/movies/components/movie-detail/movie-detail.component.css @@ -0,0 +1,9 @@ +mat-card-actions { + margin-bottom: 0; +} +mat-card-header { + margin-bottom: 10px; +} +.full-width { + width: 100%; +} diff --git a/src/app/movies/components/movie-detail/movie-detail.component.html b/src/app/movies/components/movie-detail/movie-detail.component.html new file mode 100755 index 0000000..d941819 --- /dev/null +++ b/src/app/movies/components/movie-detail/movie-detail.component.html @@ -0,0 +1,27 @@ + + + +

+ Editing {{originalMovie.name}} + Create Movie +

+
+
+
+ + + + + + + + + + + + + + + +
+
diff --git a/src/app/movies/components/movie-detail/movie-detail.component.spec.ts b/src/app/movies/components/movie-detail/movie-detail.component.spec.ts new file mode 100755 index 0000000..0cac697 --- /dev/null +++ b/src/app/movies/components/movie-detail/movie-detail.component.spec.ts @@ -0,0 +1,11 @@ +/* tslint:disable:no-unused-variable */ + +import { TestBed, async } from '@angular/core/testing'; +import { MovieDetailComponent } from './movie-detail.component'; + +describe('Component: MovieDetail', () => { + it('should create an instance', () => { + const component = new MovieDetailComponent(); + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/movies/components/movie-detail/movie-detail.component.ts b/src/app/movies/components/movie-detail/movie-detail.component.ts new file mode 100755 index 0000000..67b02da --- /dev/null +++ b/src/app/movies/components/movie-detail/movie-detail.component.ts @@ -0,0 +1,39 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { Movie } from 'src/app/shared/models/movie.model'; +import { FormGroup, FormControl } from '@angular/forms'; + +@Component({ + selector: 'app-movie-detail', + templateUrl: './movie-detail.component.html', + styleUrls: ['./movie-detail.component.css'] +}) +export class MovieDetailComponent { + originalMovie: Movie | undefined; + @Output() save = new EventEmitter(); + @Output() cancel = new EventEmitter(); + + movieForm = new FormGroup({ + name: new FormControl(''), + earnings: new FormControl(0), + description: new FormControl('') + }); + + @Input() set movie(movie: Movie) { + this.movieForm.reset(); + this.originalMovie = null; + + if (movie) { + this.movieForm.setValue({ + name: movie.name, + earnings: movie.earnings, + description: movie.description + }); + + this.originalMovie = movie; + } + } + + onSubmit(movie: Movie) { + this.save.emit({ ...this.originalMovie, ...movie }); + } +} diff --git a/src/app/movies/components/movies-list/movies-list.component.css b/src/app/movies/components/movies-list/movies-list.component.css new file mode 100755 index 0000000..1ffd996 --- /dev/null +++ b/src/app/movies/components/movies-list/movies-list.component.css @@ -0,0 +1,7 @@ +mat-list-item:not(:first-of-type) { + border-top: 1px solid #efefef; +} + +.symbol { + color: #777; +} diff --git a/src/app/movies/components/movies-list/movies-list.component.html b/src/app/movies/components/movies-list/movies-list.component.html new file mode 100755 index 0000000..91b8ce5 --- /dev/null +++ b/src/app/movies/components/movies-list/movies-list.component.html @@ -0,0 +1,23 @@ + + + +

Movies

+
+
+ + + +

{{movie.name}}

+

+ {{movie.description}} +

+

+ {{movie.earnings | currency}} +

+ +
+
+
+
diff --git a/src/app/movies/components/movies-list/movies-list.component.spec.ts b/src/app/movies/components/movies-list/movies-list.component.spec.ts new file mode 100755 index 0000000..1dc2f37 --- /dev/null +++ b/src/app/movies/components/movies-list/movies-list.component.spec.ts @@ -0,0 +1,11 @@ +/* tslint:disable:no-unused-variable */ + +import { TestBed, async } from '@angular/core/testing'; +import { MoviesListComponent } from './movies-list.component'; + +describe('Component: MoviesList', () => { + it('should create an instance', () => { + const component = new MoviesListComponent(); + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/movies/components/movies-list/movies-list.component.ts b/src/app/movies/components/movies-list/movies-list.component.ts new file mode 100755 index 0000000..7326338 --- /dev/null +++ b/src/app/movies/components/movies-list/movies-list.component.ts @@ -0,0 +1,14 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { Movie } from 'src/app/shared/models/movie.model'; + +@Component({ + selector: 'app-movies-list', + templateUrl: './movies-list.component.html', + styleUrls: ['./movies-list.component.css'] +}) +export class MoviesListComponent { + @Input() movies: Movie[]; + @Input() readonly = false; + @Output() select = new EventEmitter(); + @Output() delete = new EventEmitter(); +} diff --git a/src/app/movies/components/movies-page/movies-page.component.css b/src/app/movies/components/movies-page/movies-page.component.css new file mode 100755 index 0000000..40d8251 --- /dev/null +++ b/src/app/movies/components/movies-page/movies-page.component.css @@ -0,0 +1,4 @@ +:host >>> mat-list-item:hover { + cursor: pointer; + background: whitesmoke; +} diff --git a/src/app/movies/components/movies-page/movies-page.component.html b/src/app/movies/components/movies-page/movies-page.component.html new file mode 100755 index 0000000..ad21bbd --- /dev/null +++ b/src/app/movies/components/movies-page/movies-page.component.html @@ -0,0 +1,19 @@ +
+
+ + + + + +
+ + + +
diff --git a/src/app/movies/components/movies-page/movies-page.component.spec.ts b/src/app/movies/components/movies-page/movies-page.component.spec.ts new file mode 100755 index 0000000..aa692bf --- /dev/null +++ b/src/app/movies/components/movies-page/movies-page.component.spec.ts @@ -0,0 +1,104 @@ +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { provideMockStore, MockStore } from '@ngrx/store/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { Store } from '@ngrx/store'; + +import { MaterialModule } from 'src/app/material.module'; + +import { MoviesPageActions, MovieApiActions } from '../../actions'; +import * as fromRoot from 'src/app/shared/state'; +import { Movie } from 'src/app/shared/models/movie.model'; + +import { MoviesPageComponent } from './movies-page.component'; +import { MoviesListComponent } from '../movies-list/movies-list.component'; +import { MovieDetailComponent } from '../movie-detail/movie-detail.component'; +import { MoviesTotalComponent } from '../movies-total/movies-total.component'; + +describe('Component: Movies Page', () => { + let comp: MoviesPageComponent; + let fixture: ComponentFixture; + let store: MockStore<{ movies: Movie[] }>; + + beforeEach(async () => { + TestBed.configureTestingModule({ + imports: [ + MaterialModule, + NoopAnimationsModule, + ReactiveFormsModule + ], + declarations: [ + MoviesPageComponent, + MoviesListComponent, + MovieDetailComponent, + MoviesTotalComponent + ], + providers: [ + provideMockStore() + ] + }); + + fixture = TestBed.createComponent(MoviesPageComponent); + comp = fixture.componentInstance; + store = TestBed.get(Store); + + spyOn(store, 'dispatch').and.callThrough(); + }); + + it('should create an instance', () => { + expect(comp).toBeTruthy(); + }); + + it('should display an Enter action on init', () => { + const action = MoviesPageActions.enter(); + + comp.ngOnInit(); + + expect(store.dispatch).toHaveBeenCalledWith(action); + }); + + it('should dispatch an select action on a select event from the movie list', () => { + const movie: Movie = { id: "1", name: 'Movie', earnings: 25 }; + const action = MoviesPageActions.selectMovie({ movieId: movie.id }); + + fixture.debugElement.query(By.css('app-movies-list')).triggerEventHandler('select', movie); + + expect(store.dispatch).toHaveBeenCalledWith(action); + }); + + it('should dispatch an delete action on a delete event from the movie list', () => { + const movie: Movie = { id: "1", name: 'Movie', earnings: 25 }; + const action = MoviesPageActions.deleteMovie({ movie }); + + fixture.debugElement.query(By.css('app-movies-list')).triggerEventHandler('delete', movie); + + expect(store.dispatch).toHaveBeenCalledWith(action); + }); + + it('should dispatch an save action on a save event from the movie details', () => { + const movie: Movie = { id: undefined, name: 'Movie', earnings: 25 }; + const action = MoviesPageActions.createMovie({ movie }); + + fixture.debugElement.query(By.css('app-movie-detail')).triggerEventHandler('save', movie); + + expect(store.dispatch).toHaveBeenCalledWith(action); + }); + + it('should dispatch an update action on a delete event from the movie details', () => { + const movie: Movie = { id: "1", name: 'Movie', earnings: 25 }; + const action = MoviesPageActions.updateMovie({ movie, changes: movie }); + + fixture.debugElement.query(By.css('app-movie-detail')).triggerEventHandler('save', movie); + + expect(store.dispatch).toHaveBeenCalledWith(action); + }); + + it('should dispatch an clear action on a cancel event from the movie details', () => { + const action = MoviesPageActions.clearSelectedMovie(); + + fixture.debugElement.query(By.css('app-movie-detail')).triggerEventHandler('cancel', null); + + expect(store.dispatch).toHaveBeenCalledWith(action); + }); +}); diff --git a/src/app/movies/components/movies-page/movies-page.component.ts b/src/app/movies/components/movies-page/movies-page.component.ts new file mode 100755 index 0000000..725eb25 --- /dev/null +++ b/src/app/movies/components/movies-page/movies-page.component.ts @@ -0,0 +1,51 @@ +import { Component, OnInit } from '@angular/core'; +import { Store, select } from '@ngrx/store'; + +import { MoviesPageActions } from '../../actions'; +import { Movie } from 'src/app/shared/models/movie.model'; +import * as fromRoot from 'src/app/shared/state'; + +@Component({ + selector: 'app-movies', + templateUrl: './movies-page.component.html', + styleUrls: ['./movies-page.component.css'] +}) +export class MoviesPageComponent implements OnInit { + movies$ = this.store.pipe(select(fromRoot.selectMovies)); + currentMovie$ = this.store.pipe(select(fromRoot.selectCurrentMovie)); + total$ = this.store.pipe(select(fromRoot.selectMoviesEarningsTotal)); + + constructor(private store: Store) {} + + ngOnInit() { + this.store.dispatch(MoviesPageActions.enter()); + } + + onSelect(movie: Movie) { + this.store.dispatch(MoviesPageActions.selectMovie({movieId: movie.id})); + } + + onCancel() { + this.store.dispatch(MoviesPageActions.clearSelectedMovie()); + } + + onSave(movie: Movie) { + if (!movie.id) { + this.saveMovie(movie); + } else { + this.updateMovie(movie); + } + } + + saveMovie(movie: Movie) { + this.store.dispatch(MoviesPageActions.createMovie({movie})); + } + + updateMovie(movie: Movie) { + this.store.dispatch(MoviesPageActions.updateMovie({movie, changes: movie})); + } + + onDelete(movie: Movie) { + this.store.dispatch(MoviesPageActions.deleteMovie({movie})); + } +} diff --git a/src/app/movies/components/movies-total/movies-total.component.css b/src/app/movies/components/movies-total/movies-total.component.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/movies/components/movies-total/movies-total.component.html b/src/app/movies/components/movies-total/movies-total.component.html new file mode 100644 index 0000000..abd40d8 --- /dev/null +++ b/src/app/movies/components/movies-total/movies-total.component.html @@ -0,0 +1,11 @@ + + + +

Movies Gross Total

+
+
+ + {{ total | currency }} + +
+ diff --git a/src/app/movies/components/movies-total/movies-total.component.spec.ts b/src/app/movies/components/movies-total/movies-total.component.spec.ts new file mode 100644 index 0000000..b2dd719 --- /dev/null +++ b/src/app/movies/components/movies-total/movies-total.component.spec.ts @@ -0,0 +1,31 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MoviesTotalComponent } from './movies-total.component'; +import { MaterialModule } from 'src/app/material.module'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +describe('MoviesTotalComponent', () => { + let component: MoviesTotalComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + MaterialModule, + NoopAnimationsModule + ], + declarations: [ MoviesTotalComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MoviesTotalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/movies/components/movies-total/movies-total.component.ts b/src/app/movies/components/movies-total/movies-total.component.ts new file mode 100644 index 0000000..082e539 --- /dev/null +++ b/src/app/movies/components/movies-total/movies-total.component.ts @@ -0,0 +1,10 @@ +import { Component, Input } from '@angular/core'; + +@Component({ + selector: 'app-movies-total', + templateUrl: './movies-total.component.html', + styleUrls: ['./movies-total.component.css'] +}) +export class MoviesTotalComponent { + @Input() total: number; +} diff --git a/src/app/movies/movie-api.effects.spec.ts b/src/app/movies/movie-api.effects.spec.ts index a984526..5b80c24 100644 --- a/src/app/movies/movie-api.effects.spec.ts +++ b/src/app/movies/movie-api.effects.spec.ts @@ -3,9 +3,9 @@ import { provideMockActions } from "@ngrx/effects/testing"; import { Action } from "@ngrx/store"; import { Observable } from "rxjs"; import { hot, cold } from "jasmine-marbles"; -import { MovieService } from "../shared/services/movie.service"; +import { MoviesService } from "../shared/services/movies.service"; import { Movie } from "../shared/models/movie.model"; -import { MoviePageActions, MovieApiActions } from "./actions"; +import { MoviesPageActions, MovieApiActions } from "./actions"; import { MovieApiEffects } from "./movie-api.effects"; describe("Movie API Effects", () => { @@ -20,7 +20,7 @@ describe("Movie API Effects", () => { const mockMovie: Movie = { id: "test", name: "Mock Movie", - rating: 1 + earnings: 25, }; beforeEach(() => { @@ -29,7 +29,7 @@ describe("Movie API Effects", () => { MovieApiEffects, provideMockActions(() => actions$), { - provide: MovieService, + provide: MoviesService, useFactory() { mockMovieService = { create: jest.fn(), @@ -47,10 +47,10 @@ describe("Movie API Effects", () => { }); it("should use the API to create a movie", () => { - const inputAction = MoviePageActions.createMovie({ + const inputAction = MoviesPageActions.createMovie({ movie: { name: mockMovie.name, - rating: mockMovie.rating + earnings: 25, } }); const outputAction = MovieApiActions.createMovieSuccess({ diff --git a/src/app/movies/movie-api.effects.ts b/src/app/movies/movie-api.effects.ts index 31fa0a1..3b45908 100644 --- a/src/app/movies/movie-api.effects.ts +++ b/src/app/movies/movie-api.effects.ts @@ -2,18 +2,29 @@ import { Injectable } from "@angular/core"; import { Effect, Actions, ofType } from "@ngrx/effects"; import { of } from "rxjs"; import { mergeMap, map, catchError, concatMap } from "rxjs/operators"; -import { MovieApiActions, MoviePageActions } from "./actions"; -import { MovieService } from "../shared/services/movie.service"; +import { MovieApiActions, MoviesPageActions } from "./actions"; +import { MoviesService } from "../shared/services/movies.service"; @Injectable() export class MovieApiEffects { + constructor( - private actions$: Actions, - private movieService: MovieService + private actions$: Actions, + private movieService: MoviesService ) {} + @Effect() enterMoviesPage$ = this.actions$.pipe( + ofType(MoviesPageActions.enter.type), + mergeMap(() => + this.movieService.all().pipe( + map(movies => MovieApiActions.loadMoviesSuccess({ movies })), + catchError(() => of(MovieApiActions.loadMoviesFailure())) + ) + ) + ); + @Effect() createMovie$ = this.actions$.pipe( - ofType(MoviePageActions.createMovie.type), + ofType(MoviesPageActions.createMovie.type), mergeMap(action => this.movieService.create(action.movie).pipe( map(movie => MovieApiActions.createMovieSuccess({ movie })), @@ -23,7 +34,7 @@ export class MovieApiEffects { ); @Effect() updateMovie$ = this.actions$.pipe( - ofType(MoviePageActions.updateMovie.type), + ofType(MoviesPageActions.updateMovie.type), concatMap(action => this.movieService.update(action.movie.id, action.changes).pipe( map(movie => MovieApiActions.updateMovieSuccess({ movie })), @@ -35,7 +46,7 @@ export class MovieApiEffects { ); @Effect() deleteMovie$ = this.actions$.pipe( - ofType(MoviePageActions.deleteMovie.type), + ofType(MoviesPageActions.deleteMovie.type), mergeMap(action => this.movieService.delete(action.movie.id).pipe( map(() => diff --git a/src/app/movies/movies.module.ts b/src/app/movies/movies.module.ts index e153b1f..74149b2 100644 --- a/src/app/movies/movies.module.ts +++ b/src/app/movies/movies.module.ts @@ -1,6 +1,33 @@ import { NgModule } from "@angular/core"; +import { CommonModule } from '@angular/common'; +import { RouterModule } from '@angular/router'; +import { ReactiveFormsModule } from '@angular/forms'; + import { EffectsModule } from "@ngrx/effects"; + +import { MaterialModule } from 'src/app/material.module'; import { MovieApiEffects } from "./movie-api.effects"; -@NgModule({ imports: [EffectsModule.forFeature([MovieApiEffects])] }) +import { MoviesPageComponent } from './components/movies-page/movies-page.component'; +import { MovieDetailComponent } from './components/movie-detail/movie-detail.component'; +import { MoviesListComponent } from './components/movies-list/movies-list.component'; +import { MoviesTotalComponent } from './components/movies-total/movies-total.component'; + +@NgModule({ + imports: [ + CommonModule, + ReactiveFormsModule, + MaterialModule, + RouterModule.forChild([ + { path: 'movies', component: MoviesPageComponent } + ]), + EffectsModule.forFeature([MovieApiEffects]) + ], + declarations: [ + MoviesPageComponent, + MovieDetailComponent, + MoviesListComponent, + MoviesTotalComponent + ] +}) export class MoviesModule {} diff --git a/src/app/shared/models/book.model.ts b/src/app/shared/models/book.model.ts new file mode 100644 index 0000000..6bfaaaf --- /dev/null +++ b/src/app/shared/models/book.model.ts @@ -0,0 +1,8 @@ +export interface Book { + id: string; + name: string; + earnings: number; + description?: string; +} + +export type BookRequiredProps = Pick; diff --git a/src/app/shared/models/movie.model.ts b/src/app/shared/models/movie.model.ts index 74cb52b..3f1269e 100644 --- a/src/app/shared/models/movie.model.ts +++ b/src/app/shared/models/movie.model.ts @@ -1,7 +1,8 @@ export interface Movie { id: string; name: string; - rating: number; + earnings: number; + description?: string; } -export type MovieRequiredProps = Pick; +export type MovieRequiredProps = Pick; diff --git a/src/app/shared/services/book.service.ts b/src/app/shared/services/book.service.ts new file mode 100644 index 0000000..77ca751 --- /dev/null +++ b/src/app/shared/services/book.service.ts @@ -0,0 +1,45 @@ +import { HttpClient, HttpHeaders } from "@angular/common/http"; +import { Injectable } from "@angular/core"; +import * as uuid from "uuid/v4"; +import { Book, BookRequiredProps } from "../models/book.model"; + +const BASE_URL = "http://localhost:3000/books"; +const HEADER = { + headers: new HttpHeaders({ "Content-Type": "application/json" }) +}; + +@Injectable({ + providedIn: "root" +}) +export class BooksService { + constructor(private http: HttpClient) {} + + all() { + return this.http.get(BASE_URL); + } + + load(id: string) { + return this.http.get(`${BASE_URL}/${id}`); + } + + create(bookProps: BookRequiredProps) { + const Book: Book = { + id: uuid(), + ...bookProps + }; + + return this.http.post(`${BASE_URL}`, JSON.stringify(Book), HEADER); + } + + update(id: string, updates: BookRequiredProps) { + return this.http.patch( + `${BASE_URL}/${id}`, + JSON.stringify(updates), + HEADER + ); + } + + delete(id: string) { + return this.http.delete(`${BASE_URL}/${id}`); + } +} diff --git a/src/app/shared/services/movie.service.ts b/src/app/shared/services/movies.service.ts similarity index 84% rename from src/app/shared/services/movie.service.ts rename to src/app/shared/services/movies.service.ts index 240ca6b..b3a858e 100644 --- a/src/app/shared/services/movie.service.ts +++ b/src/app/shared/services/movies.service.ts @@ -8,16 +8,18 @@ const HEADER = { headers: new HttpHeaders({ "Content-Type": "application/json" }) }; -@Injectable({ providedIn: "root" }) -export class MovieService { +@Injectable({ + providedIn: "root" +}) +export class MoviesService { constructor(private http: HttpClient) {} all() { - return this.http.get(BASE_URL); + return this.http.get(BASE_URL); } load(id: string) { - return this.http.get(`${BASE_URL}/${id}`); + return this.http.get(`${BASE_URL}/${id}`); } create(movieProps: MovieRequiredProps) { diff --git a/src/app/shared/state/__snapshots__/movie.reducer.spec.ts.snap b/src/app/shared/state/__snapshots__/movie.reducer.spec.ts.snap index 2232b37..15a844d 100644 --- a/src/app/shared/state/__snapshots__/movie.reducer.spec.ts.snap +++ b/src/app/shared/state/__snapshots__/movie.reducer.spec.ts.snap @@ -2,11 +2,12 @@ exports[`Movie Reducer should add newly created movies to the state 1`] = ` Object { + "activeMovieId": "1", "entities": Object { "1": Object { + "earnings": 100000, "id": "1", "name": "Arrival", - "rating": 4, }, }, "ids": Array [ @@ -17,11 +18,12 @@ Object { exports[`Movie Reducer should apply changes to a movie when a movie is updated 1`] = ` Object { + "activeMovieId": "1", "entities": Object { "1": Object { + "earnings": 120000, "id": "1", - "name": "Blade Runner (Final Cut)", - "rating": 4.5, + "name": "Blade Runner", }, }, "ids": Array [ @@ -32,11 +34,12 @@ Object { exports[`Movie Reducer should load all movies when the API loads them all successfully 1`] = ` Object { + "activeMovieId": null, "entities": Object { "1": Object { + "earnings": 0, "id": "1", "name": "Green Lantern", - "rating": 0, }, }, "ids": Array [ @@ -47,18 +50,28 @@ Object { exports[`Movie Reducer should remove movies from the state when they are deleted 1`] = ` Object { - "entities": Object {}, - "ids": Array [], + "activeMovieId": "1", + "entities": Object { + "1": Object { + "earnings": 1000, + "id": "1", + "name": "mother!", + }, + }, + "ids": Array [ + "1", + ], } `; exports[`Movie Reducer should roll back a deletion if deleting a movie fails 1`] = ` Object { + "activeMovieId": "1", "entities": Object { "1": Object { + "earnings": 10000, "id": "1", "name": "Black Panther", - "rating": 4, }, }, "ids": Array [ @@ -69,11 +82,12 @@ Object { exports[`Movie Reducer should rollback changes to a movie if there is an error when updating it with the API 1`] = ` Object { + "activeMovieId": "1", "entities": Object { "1": Object { + "earnings": 10000000000, "id": "1", "name": "Star Wars: A New Hope", - "rating": 3.5, }, }, "ids": Array [ diff --git a/src/app/shared/state/books.reducer.spec.ts b/src/app/shared/state/books.reducer.spec.ts new file mode 100644 index 0000000..ace7bc5 --- /dev/null +++ b/src/app/shared/state/books.reducer.spec.ts @@ -0,0 +1,9 @@ +// import { BooksApiActions, BooksPageActions } from "src/app/books/actions"; +// import { Book } from "../models/book.model"; +// import { reducer, initialState } from "./book.reducer"; + +describe("Books Reducer", () => { + it("should return the initial state when initialized", () => { + + }); +}); \ No newline at end of file diff --git a/src/app/shared/state/books.reducer.ts b/src/app/shared/state/books.reducer.ts new file mode 100644 index 0000000..7d85022 --- /dev/null +++ b/src/app/shared/state/books.reducer.ts @@ -0,0 +1,29 @@ +import { createEntityAdapter, EntityAdapter, EntityState } from '@ngrx/entity'; +import { Book } from 'src/app/shared/models/book.model'; + +const initialBooks: Book[] = [ + { + id: "1", + name: "Fellowship of the Ring", + earnings: 100000000, + description: "The start" + }, + { + id: "2", + name: "The Two Towers", + earnings: 200000000, + description: "The middle" + }, + { + id: "3", + name: "The Return of The King", + earnings: 400000000, + description: "The end" + }, +]; + +const createBook = (books: Book[], book: Book) => [...books, book]; +const updateBook = (books: Book[], book: Book) => books.map(w => { + return w.id === book.id ? Object.assign({}, book) : w; +}); +const deleteBook = (books: Book[], book: Book) => books.filter(w => book.id !== w.id); diff --git a/src/app/shared/state/index.ts b/src/app/shared/state/index.ts index 37e0649..5229521 100644 --- a/src/app/shared/state/index.ts +++ b/src/app/shared/state/index.ts @@ -9,17 +9,35 @@ export const reducers: ActionReducerMap = { movies: fromMovies.reducer }; +export const metaReducers: MetaReducer[] = []; + /** * Selectors */ export const selectMovieState = (state: State) => state.movies; + export const selectMovieEntities = createSelector( selectMovieState, fromMovies.selectEntities ); + export const selectMovies = createSelector( selectMovieState, fromMovies.selectAll ); -export const metaReducers: MetaReducer[] = []; +export const selectActiveMovieId = createSelector( + selectMovieState, + fromMovies.selectActiveMovieId +); + +export const selectCurrentMovie = createSelector( + selectMovieEntities, + selectActiveMovieId, + (movies, activeMovieId) => activeMovieId && movies[activeMovieId] +); + +export const selectMoviesEarningsTotal = createSelector( + selectMovies, + movies => movies.reduce((total, movie) => total + parseInt(`${movie.earnings}`, 10) || 0, 0) +); diff --git a/src/app/shared/state/movie.reducer.spec.ts b/src/app/shared/state/movie.reducer.spec.ts index b5fa785..5627e84 100644 --- a/src/app/shared/state/movie.reducer.spec.ts +++ b/src/app/shared/state/movie.reducer.spec.ts @@ -1,4 +1,4 @@ -import { MovieApiActions, MoviePageActions } from "src/app/movies/actions"; +import { MovieApiActions, MoviesPageActions } from "src/app/movies/actions"; import { Movie } from "../models/movie.model"; import { reducer, initialState } from "./movie.reducer"; @@ -10,7 +10,7 @@ describe("Movie Reducer", () => { }); it("should load all movies when the API loads them all successfully", () => { - const movies: Movie[] = [{ id: "1", name: "Green Lantern", rating: 0 }]; + const movies: Movie[] = [{ id: "1", name: "Green Lantern", earnings: 0 }]; const action = MovieApiActions.loadMoviesSuccess({ movies }); const state = reducer(initialState, action); @@ -19,7 +19,7 @@ describe("Movie Reducer", () => { }); it("should add newly created movies to the state", () => { - const movie: Movie = { id: "1", name: "Arrival", rating: 4 }; + const movie: Movie = { id: "1", name: "Arrival", earnings: 100000 }; const action = MovieApiActions.createMovieSuccess({ movie }); const state = reducer(initialState, action); @@ -28,9 +28,9 @@ describe("Movie Reducer", () => { }); it("should remove movies from the state when they are deleted", () => { - const movie: Movie = { id: "1", name: "mother!", rating: 1.5 }; + const movie: Movie = { id: "1", name: "mother!", earnings: 1000 }; const firstAction = MovieApiActions.createMovieSuccess({ movie }); - const secondAction = MoviePageActions.deleteMovie({ movie }); + const secondAction = MoviesPageActions.deleteMovie({ movie }); const state = [firstAction, secondAction].reduce(reducer, initialState); @@ -38,9 +38,9 @@ describe("Movie Reducer", () => { }); it("should roll back a deletion if deleting a movie fails", () => { - const movie: Movie = { id: "1", name: "Black Panther", rating: 4 }; + const movie: Movie = { id: "1", name: "Black Panther", earnings: 10000 }; const firstAction = MovieApiActions.createMovieSuccess({ movie }); - const secondAction = MoviePageActions.deleteMovie({ movie }); + const secondAction = MoviesPageActions.deleteMovie({ movie }); const thirdAction = MovieApiActions.deleteMovieFailure({ movie }); const state = [firstAction, secondAction, thirdAction].reduce( @@ -52,10 +52,10 @@ describe("Movie Reducer", () => { }); it("should apply changes to a movie when a movie is updated", () => { - const movie: Movie = { id: "1", name: "Blade Runner", rating: 2 }; - const changes = { name: "Blade Runner (Final Cut)", rating: 4.5 }; + const movie: Movie = { id: "1", name: "Blade Runner", earnings: 120000 }; + const changes = { name: "Blade Runner (Final Cut)", earnings: 150000 }; const firstAction = MovieApiActions.createMovieSuccess({ movie }); - const secondAction = MoviePageActions.updateMovie({ movie, changes }); + const secondAction = MoviesPageActions.updateMovie({ movie, changes }); const state = [firstAction, secondAction].reduce(reducer, initialState); @@ -66,14 +66,14 @@ describe("Movie Reducer", () => { const movie: Movie = { id: "1", name: "Star Wars: A New Hope", - rating: 3.5 + earnings: 10000000000 }; const changes = { name: "Star Wars: A New Hope (Special Edition)", - rating: 2.5 + earnings: 12000000000 }; const firstAction = MovieApiActions.createMovieSuccess({ movie }); - const secondAction = MoviePageActions.updateMovie({ movie, changes }); + const secondAction = MoviesPageActions.updateMovie({ movie, changes }); const thirdAction = MovieApiActions.updateMovieFailure({ movie }); const state = [firstAction, secondAction, thirdAction].reduce( diff --git a/src/app/shared/state/movie.reducer.ts b/src/app/shared/state/movie.reducer.ts index 91267f9..771bea5 100644 --- a/src/app/shared/state/movie.reducer.ts +++ b/src/app/shared/state/movie.reducer.ts @@ -1,47 +1,52 @@ import { createEntityAdapter, EntityState } from "@ngrx/entity"; import { Movie } from "../models/movie.model"; -import { MovieApiActions, MoviePageActions } from "src/app/movies/actions"; +import { MovieApiActions, MoviesPageActions } from "src/app/movies/actions"; const adapter = createEntityAdapter({ selectId: (movie: Movie) => movie.id, sortComparer: (a: Movie, b: Movie) => - a.name.localeCompare(b.name, null, { numeric: true }) + a.name.localeCompare(b.name) }); -export interface State extends EntityState {} +export interface State extends EntityState { + activeMovieId: string | null; +} -export const initialState: State = adapter.getInitialState(); +export const initialState: State = adapter.getInitialState({ + activeMovieId: null +}); export function reducer( state: State = initialState, - action: MovieApiActions.Union | MoviePageActions.Union + action: MovieApiActions.Union | MoviesPageActions.Union ): State { switch (action.type) { - case MovieApiActions.loadMoviesSuccess.type: { - return adapter.addAll(action.movies, state); + case MoviesPageActions.enter.type: { + return {...state, activeMovieId: null}; } - case MovieApiActions.createMovieSuccess.type: { - return adapter.addOne(action.movie, state); + case MoviesPageActions.selectMovie.type: { + return {...state, activeMovieId: action.movieId}; } - case MoviePageActions.deleteMovie.type: { - return adapter.removeOne(action.movie.id, state); - } + case MoviesPageActions.clearSelectedMovie.type: { + return {...state, activeMovieId: null}; + } - case MovieApiActions.deleteMovieFailure.type: { - return adapter.addOne(action.movie, state); + case MovieApiActions.loadMoviesSuccess.type: { + return adapter.addAll(action.movies, state); } - - case MoviePageActions.updateMovie.type: { - return adapter.updateOne( - { id: action.movie.id, changes: action.changes }, - state - ); + + case MovieApiActions.createMovieSuccess.type: { + return adapter.addOne(action.movie, {...state, activeMovieId: action.movie.id}); + } + + case MovieApiActions.updateMovieSuccess.type: { + return adapter.updateOne({id: action.movie.id, changes: action.movie}, {...state, activeMovieId: action.movie.id}); } - case MovieApiActions.updateMovieFailure.type: { - return adapter.upsertOne(action.movie, state); + case MovieApiActions.deleteMovieSuccess.type: { + return adapter.removeOne(action.movieId, {...state, activeMovieId: null}); } default: { @@ -51,3 +56,4 @@ export function reducer( } export const { selectEntities, selectAll } = adapter.getSelectors(); +export const selectActiveMovieId = (state: State) => state.activeMovieId; \ No newline at end of file diff --git a/src/index.html b/src/index.html index 986807a..8c4b229 100644 --- a/src/index.html +++ b/src/index.html @@ -2,13 +2,13 @@ - NgrxWorkshopExample + NgRx Workshop - + diff --git a/src/styles.css b/src/styles.css index 90d4ee0..dbf13ac 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1 +1,110 @@ /* You can add global styles to this file, and also import other style files */ +@import 'https://fonts.googleapis.com/icon?family=Material+Icons'; +@import '~@angular/material/prebuilt-themes/deeppurple-amber.css'; + +html { + height: 100%; +} + +body { + margin: 0; + font-family: Roboto, sans-serif; + height: 100%; + display: flex; +} + +mat-toolbar-row { + justify-content: space-between; +} + +p { + margin: 16px; +} + +[mat-raised-button] { + width: 100%; +} + +mat-grid-list { + max-width: 1403px; + margin: 16px; +} + +mat-sidenav-layout { + height: 100vh; +} + +mat-sidenav { + width: 320px; +} + +mat-sidenav a { + box-sizing: border-box; + display: block; + font-size: 14px; + font-weight: 400; + line-height: 47px; + text-decoration: none; + -webkit-transition: all .3s; + transition: all .3s; + padding: 0 16px; + position: relative; +} + +.icon-20 { + font-size: 20px; +} + +* { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +table { + border-collapse: collapse; + border-radius: 2px; + border-spacing: 0; + margin: 0 0 32px; + width: 100%; + box-shadow: 0 2px 2px rgba(0, 0, 0, .24), 0 0 2px rgba(0, 0, 0, .12); +} + +th { + font-size: 16px; + font-weight: 400; + padding: 13px 32px; + text-align: left; + color: rgba(0, 0, 0, .54); + background: rgba(0, 0, 0, .03); +} + +td { + color: rgba(0, 0, 0, .54); + border: 1px solid rgba(0, 0, 0, .03); + font-weight: 400; + padding: 8px 30px; +} + +.container { + display: flex; + margin: 10px; + flex-wrap: wrap; +} + +.container [class*="col"] { + padding: 10px; + flex: 1; +} + +mat-card-header .mat-card-header-text { + margin-left: 0; + border-bottom: 1px solid #ffd740; +} + +mat-card-title h1 { + display: inline; +} + +mat-card { + margin-bottom: 20px !important; +} diff --git a/yarn.lock b/yarn.lock index 34650b3..504ceef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -107,6 +107,15 @@ dependencies: tslib "^1.9.0" +"@angular/cdk@~7.3.0": + version "7.3.7" + resolved "https://registry.yarnpkg.com/@angular/cdk/-/cdk-7.3.7.tgz#ce1ad53ba04beb9c8e950acc5691ea0143753764" + integrity sha512-xbXxhHHKGkVuW6K7pzPmvpJXIwpl0ykBnvA2g+/7Sgy5Pd35wCC+UtHD9RYczDM/mkygNxMQtagyCErwFnDtQA== + dependencies: + tslib "^1.7.1" + optionalDependencies: + parse5 "^5.0.0" + "@angular/cli@~7.3.8": version "7.3.8" resolved "https://registry.yarnpkg.com/@angular/cli/-/cli-7.3.8.tgz#a0891e08bee7d68b9ef2c00a9a7e93c6c5ac75d7" @@ -176,6 +185,13 @@ resolved "https://registry.yarnpkg.com/@angular/language-service/-/language-service-7.2.12.tgz#ed4d98086b97e4b52acac988dc0cf9b66ffb86bc" integrity sha512-dHHcAtCQ+ECoZa/bkm1diMZuxy/e+x2/qzClfKquO47EPqOIXYKCKZRqgGNHxdbUSRpmIEanfj/li4S7doCHZw== +"@angular/material@~7.3.0": + version "7.3.7" + resolved "https://registry.yarnpkg.com/@angular/material/-/material-7.3.7.tgz#dcd95e6618ba6254c5880efee1aad349cf5b9140" + integrity sha512-Eq+7frkeNGkLOfEtmkmJgR+AgoWajOipXZWWfCSamNfpCcPof82DwvGOpAmgGni9FuN2XFQdqP5MoaffQzIvUA== + dependencies: + tslib "^1.7.1" + "@angular/platform-browser-dynamic@~7.2.0": version "7.2.12" resolved "https://registry.yarnpkg.com/@angular/platform-browser-dynamic/-/platform-browser-dynamic-7.2.12.tgz#be125de4c305d9fbbb4c65764879f7cc27177193" @@ -488,6 +504,16 @@ resolved "https://registry.yarnpkg.com/@ngrx/entity/-/entity-7.4.0.tgz#634cdff1db9629ca0e64c1d6b1e43dc15f4e2ca6" integrity sha512-aFRDTNp6IFkYFlP9gV6hgNgtDYot9KYF8WVbaQTao9ihmdPumMBOCeRttPPiHS/cU41w9nW3xF53NgxQPnEiQA== +"@ngrx/router-store@^7.4.0": + version "7.4.0" + resolved "https://registry.yarnpkg.com/@ngrx/router-store/-/router-store-7.4.0.tgz#69c085bda3022117169f87ed5753b951de7d376d" + integrity sha512-ZpwTO1/ha3pxO7NV3jIfnwipBN1A719IjAOgrcmI8Ut06VH3HY/7JVFTkwLN/FyuHvl4EOlAVYmMAblmrymUWA== + +"@ngrx/store-devtools@^7.4.0": + version "7.4.0" + resolved "https://registry.yarnpkg.com/@ngrx/store-devtools/-/store-devtools-7.4.0.tgz#5a73469c70322351c4224f4529c5123f587a5997" + integrity sha512-ZmPpquprBYUozbLuLMLZzUhI+LnMNGMNg8x1ij9yDxXWQADcJm1Zu7kouYE1r5SoCYxKfwJ3Ia1VQfS3A5S8dw== + "@ngrx/store@^7.4.0": version "7.4.0" resolved "https://registry.yarnpkg.com/@ngrx/store/-/store-7.4.0.tgz#525a343aa45d7f6ca60f3301a23a27669c14bbce" @@ -5884,7 +5910,7 @@ parse5@4.0.0: resolved "https://registry.yarnpkg.com/parse5/-/parse5-4.0.0.tgz#6d78656e3da8d78b4ec0b906f7c08ef1dfe3f608" integrity sha512-VrZ7eOd3T1Fk4XWNXMgiGBK/z0MG48BWG2uQNU4I72fkQuKUTZpl+u9k+CxEG0twMVzSmXEEz12z5Fnw1jIQFA== -parse5@5.1.0: +parse5@5.1.0, parse5@^5.0.0: version "5.1.0" resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.0.tgz#c59341c9723f414c452975564c7c00a68d58acd2" integrity sha512-fxNG2sQjHvlVAYmzBZS9YlDp6PTSSDwa98vkD4QgVDDCAo84z5X1t5XyJQ62ImdLXx5NdIIfihey6xpum9/gRQ== @@ -7696,7 +7722,7 @@ ts-node@~7.0.0: source-map-support "^0.5.6" yn "^2.0.0" -tslib@^1.8.0, tslib@^1.8.1, tslib@^1.9.0: +tslib@^1.7.1, tslib@^1.8.0, tslib@^1.8.1, tslib@^1.9.0: version "1.9.3" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.9.3.tgz#d7e4dd79245d85428c4d7e4822a79917954ca286" integrity sha512-4krF8scpejhaOgqzBEcGM7yDIEfi0/8+8zDRZhNZZ2kjmHJ4hv3zCbQWxoJGz1iw5U0Jl0nma13xzHXcncMavQ==