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==