diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..64795a7 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,37 @@ +name: Build and Deploy Script +on: + push: + branches: + - training + +jobs: + build: + name: Build and Deploy + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v2 + - name: Adding Node.js + uses: actions/setup-node@v2 + with: + node-version: 22.15.1 + - name: Install Dependencies + run: npm install + - name: Run Linter + run: npm run lint + - name: Run tests + run: npm run test:ci + - name: Build Angular App + run: npm run build + - name: Deploy to Pages + uses: AhsanAyaz/angular-deploy-gh-pages-actions@v1.4.0 + with: + github_access_token: ${{ secrets.GITHUB_TOKEN }} + build_configuration: production + base_href: /angular-template/ + deploy_branch: gh-pages + angular_dist_build_folder: dist/angular-template/browser + +permissions: + contents: write + diff --git a/angular.json b/angular.json index b5db2fb..ce6f9e5 100644 --- a/angular.json +++ b/angular.json @@ -31,6 +31,7 @@ "src/assets" ], "styles": [ + "@angular/material/prebuilt-themes/indigo-pink.css", "src/styles.scss" ], "scripts": [], @@ -53,6 +54,12 @@ "outputHashing": "all" }, "development": { + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.development.ts" + } + ], "optimization": false, "extractLicenses": false, "sourceMap": true, @@ -93,6 +100,7 @@ "src/assets" ], "styles": [ + "@angular/material/prebuilt-themes/azure-blue.css", "src/styles.scss" ], "scripts": [] @@ -113,6 +121,7 @@ "cli": { "schematicCollections": [ "@angular-eslint/schematics" - ] + ], + "analytics": false } -} +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 5e4c46a..fc2a062 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,13 +9,20 @@ "version": "0.0.0", "dependencies": { "@angular/animations": "^19.2.10", + "@angular/cdk": "^19.2.17", "@angular/common": "^19.2.10", "@angular/compiler": "^19.2.10", "@angular/core": "^19.2.10", "@angular/forms": "^19.2.10", + "@angular/material": "^19.2.17", "@angular/platform-browser": "^19.2.10", "@angular/platform-browser-dynamic": "^19.2.10", "@angular/router": "^19.2.10", + "@ngrx/effects": "^19.2.0", + "@ngrx/entity": "^19.2.0", + "@ngrx/router-store": "^19.2.0", + "@ngrx/store": "^19.2.0", + "@ngrx/store-devtools": "^19.2.0", "rxjs": "~7.8.0", "tslib": "^2.3.0", "zone.js": "~0.15.0" @@ -1249,6 +1256,21 @@ } } }, + "node_modules/@angular/cdk": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-19.2.17.tgz", + "integrity": "sha512-3jG33S+5+kqymCRwQlcSEWlY5rYwkKxe0onln+NXxT0/kteR02vWvv1+Li4/QqSr5JvsGHEhAFsZaR9QtOzbdA==", + "license": "MIT", + "dependencies": { + "parse5": "^7.1.2", + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/common": "^19.0.0 || ^20.0.0", + "@angular/core": "^19.0.0 || ^20.0.0", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, "node_modules/@angular/cli": { "version": "19.2.12", "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-19.2.12.tgz", @@ -1510,6 +1532,23 @@ "rxjs": "^6.5.3 || ^7.4.0" } }, + "node_modules/@angular/material": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@angular/material/-/material-19.2.17.tgz", + "integrity": "sha512-IyA+KP+uUj3r9loqGJrj7qAiEBckj7EVIdV0jlYwqWIUyKWeJ3R88GmLPMH2BgtBU3R/WkS2blXDI0yvRhKfww==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/cdk": "19.2.17", + "@angular/common": "^19.0.0 || ^20.0.0", + "@angular/core": "^19.0.0 || ^20.0.0", + "@angular/forms": "^19.0.0 || ^20.0.0", + "@angular/platform-browser": "^19.0.0 || ^20.0.0", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, "node_modules/@angular/platform-browser": { "version": "19.2.10", "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-19.2.10.tgz", @@ -4957,6 +4996,77 @@ "node": ">= 10" } }, + "node_modules/@ngrx/effects": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/@ngrx/effects/-/effects-19.2.0.tgz", + "integrity": "sha512-DIoFdEdSehAMHUNTWIdl94HjhSh1ZRx0Rgtgp1TjHHyjLiS+vbMmDgPjrCkBv5lT/pEaKbHKnYxjY3CQiW2Hsg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@angular/core": "^19.0.0", + "@ngrx/store": "19.2.0", + "rxjs": "^6.5.3 || ^7.5.0" + } + }, + "node_modules/@ngrx/entity": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/@ngrx/entity/-/entity-19.2.0.tgz", + "integrity": "sha512-JxKFBk0LAHrmCGLQFQeT8mZhwTZPKzq0m0gqCtXgmtzHj9B/ln3yluTtBWgOEf07dDAkEX/q42Sr+kO+5kctvg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@angular/core": "^19.0.0", + "@ngrx/store": "19.2.0", + "rxjs": "^6.5.3 || ^7.5.0" + } + }, + "node_modules/@ngrx/router-store": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/@ngrx/router-store/-/router-store-19.2.0.tgz", + "integrity": "sha512-emR6Y+NIcFxFt1QsyDdMIVhkuGEzawGZM5yOo8A6kUZljzf88S/7tHXQRKLz1Vy2fpDRZDO6r/0eagW0JDMfLA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@angular/common": "^19.0.0", + "@angular/core": "^19.0.0", + "@angular/router": "^19.0.0", + "@ngrx/store": "19.2.0", + "rxjs": "^6.5.3 || ^7.5.0" + } + }, + "node_modules/@ngrx/store": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/@ngrx/store/-/store-19.2.0.tgz", + "integrity": "sha512-k2n/jLJZ75Z5rd5vPa2mXPYG/On2rFLiNdrccs9Dw2r+oJosORMlN5TbdsGHhVDFfjzbY9a7JbHUE3YOa69gqw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@angular/core": "^19.0.0", + "rxjs": "^6.5.3 || ^7.5.0" + } + }, + "node_modules/@ngrx/store-devtools": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/@ngrx/store-devtools/-/store-devtools-19.2.0.tgz", + "integrity": "sha512-AKlXHsuSRJgYYxmrXZ8WWnDxqgKMG0+HP+IIDmk5h5Z5RIkOLHk6ZGKbakhIiFlL8d16N+GcJ76rqUajaHT+0w==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@angular/core": "^19.0.0", + "@ngrx/store": "19.2.0", + "rxjs": "^6.5.3 || ^7.5.0" + } + }, "node_modules/@ngtools/webpack": { "version": "19.2.12", "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-19.2.12.tgz", @@ -12761,7 +12871,6 @@ "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "dev": true, "license": "MIT", "dependencies": { "entities": "^6.0.0" @@ -12802,7 +12911,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.0.tgz", "integrity": "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -16703,6 +16811,15 @@ } } }, + "@angular/cdk": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-19.2.17.tgz", + "integrity": "sha512-3jG33S+5+kqymCRwQlcSEWlY5rYwkKxe0onln+NXxT0/kteR02vWvv1+Li4/QqSr5JvsGHEhAFsZaR9QtOzbdA==", + "requires": { + "parse5": "^7.1.2", + "tslib": "^2.3.0" + } + }, "@angular/cli": { "version": "19.2.12", "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-19.2.12.tgz", @@ -16849,6 +16966,14 @@ "tslib": "^2.3.0" } }, + "@angular/material": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@angular/material/-/material-19.2.17.tgz", + "integrity": "sha512-IyA+KP+uUj3r9loqGJrj7qAiEBckj7EVIdV0jlYwqWIUyKWeJ3R88GmLPMH2BgtBU3R/WkS2blXDI0yvRhKfww==", + "requires": { + "tslib": "^2.3.0" + } + }, "@angular/platform-browser": { "version": "19.2.10", "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-19.2.10.tgz", @@ -18844,6 +18969,46 @@ "dev": true, "optional": true }, + "@ngrx/effects": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/@ngrx/effects/-/effects-19.2.0.tgz", + "integrity": "sha512-DIoFdEdSehAMHUNTWIdl94HjhSh1ZRx0Rgtgp1TjHHyjLiS+vbMmDgPjrCkBv5lT/pEaKbHKnYxjY3CQiW2Hsg==", + "requires": { + "tslib": "^2.0.0" + } + }, + "@ngrx/entity": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/@ngrx/entity/-/entity-19.2.0.tgz", + "integrity": "sha512-JxKFBk0LAHrmCGLQFQeT8mZhwTZPKzq0m0gqCtXgmtzHj9B/ln3yluTtBWgOEf07dDAkEX/q42Sr+kO+5kctvg==", + "requires": { + "tslib": "^2.0.0" + } + }, + "@ngrx/router-store": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/@ngrx/router-store/-/router-store-19.2.0.tgz", + "integrity": "sha512-emR6Y+NIcFxFt1QsyDdMIVhkuGEzawGZM5yOo8A6kUZljzf88S/7tHXQRKLz1Vy2fpDRZDO6r/0eagW0JDMfLA==", + "requires": { + "tslib": "^2.0.0" + } + }, + "@ngrx/store": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/@ngrx/store/-/store-19.2.0.tgz", + "integrity": "sha512-k2n/jLJZ75Z5rd5vPa2mXPYG/On2rFLiNdrccs9Dw2r+oJosORMlN5TbdsGHhVDFfjzbY9a7JbHUE3YOa69gqw==", + "requires": { + "tslib": "^2.0.0" + } + }, + "@ngrx/store-devtools": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/@ngrx/store-devtools/-/store-devtools-19.2.0.tgz", + "integrity": "sha512-AKlXHsuSRJgYYxmrXZ8WWnDxqgKMG0+HP+IIDmk5h5Z5RIkOLHk6ZGKbakhIiFlL8d16N+GcJ76rqUajaHT+0w==", + "requires": { + "tslib": "^2.0.0" + } + }, "@ngtools/webpack": { "version": "19.2.12", "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-19.2.12.tgz", @@ -24143,7 +24308,6 @@ "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "dev": true, "requires": { "entities": "^6.0.0" }, @@ -24151,8 +24315,7 @@ "entities": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.0.tgz", - "integrity": "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==", - "dev": true + "integrity": "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==" } } }, diff --git a/package.json b/package.json index 6584917..10576f0 100644 --- a/package.json +++ b/package.json @@ -7,18 +7,26 @@ "build": "ng build", "watch": "ng build --watch --configuration development", "test": "ng test", + "test:ci": "ng test --no-watch --no-progress --browsers=ChromeHeadless", "lint": "ng lint" }, "private": true, "dependencies": { "@angular/animations": "^19.2.10", + "@angular/cdk": "^19.2.17", "@angular/common": "^19.2.10", "@angular/compiler": "^19.2.10", "@angular/core": "^19.2.10", "@angular/forms": "^19.2.10", + "@angular/material": "^19.2.17", "@angular/platform-browser": "^19.2.10", "@angular/platform-browser-dynamic": "^19.2.10", "@angular/router": "^19.2.10", + "@ngrx/effects": "^19.2.0", + "@ngrx/entity": "^19.2.0", + "@ngrx/router-store": "^19.2.0", + "@ngrx/store": "^19.2.0", + "@ngrx/store-devtools": "^19.2.0", "rxjs": "~7.8.0", "tslib": "^2.3.0", "zone.js": "~0.15.0" @@ -45,4 +53,4 @@ "prettier": "^3.3.3", "typescript": "~5.8.3" } -} +} \ No newline at end of file diff --git a/src/app/apis/comment.service.spec.ts b/src/app/apis/comment.service.spec.ts new file mode 100644 index 0000000..6f4771d --- /dev/null +++ b/src/app/apis/comment.service.spec.ts @@ -0,0 +1,58 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { CommentService } from './comment.service'; +import { environment } from 'src/environments/environment'; +import { Post } from '../models/post.model'; +import { Comment } from '../models/comment.model'; + +describe('CommentService', () => { + let service: CommentService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [CommentService] + }); + service = TestBed.inject(CommentService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should fetch comments by post', () => { + const post: Post = { id: 1 } as Post; + const mockComments: Comment[] = [ + { id: 1, postId: 1, body: 'Test comment' } as Comment + ]; + + service.getByPost(post).subscribe(comments => { + expect(comments).toEqual(mockComments); + }); + + const req = httpMock.expectOne(`${environment.apiUrl}/comments/?postId=1`); + expect(req.request.method).toBe('GET'); + req.flush(mockComments); + }); + + it('should handle error when fetching comments', () => { + const post: Post = { id: 2 } as Post; + const errorMsg = '404: ERROR - Unable to fetch posts'; + + service.getByPost(post).subscribe({ + next: () => fail('should have failed with error'), + error: (error) => { + expect(error).toBe(errorMsg); + } + }); + + const req = httpMock.expectOne(`${environment.apiUrl}/comments/?postId=2`); + req.flush('Not Found', { status: 404, statusText: 'Not Found' }); + }); +}); diff --git a/src/app/apis/comment.service.ts b/src/app/apis/comment.service.ts new file mode 100644 index 0000000..9dab129 --- /dev/null +++ b/src/app/apis/comment.service.ts @@ -0,0 +1,27 @@ +import { HttpClient, HttpErrorResponse } from "@angular/common/http"; +import { Injectable } from "@angular/core"; +import { catchError, Observable, throwError } from "rxjs"; +import { environment } from "src/environments/environment"; +import { Post } from "../models/post.model"; +import { Comment } from "../models/comment.model"; + +@Injectable({ + providedIn: 'root' +}) +export class CommentService { + private url = environment.apiUrl + '/comments'; + + constructor(private http: HttpClient) { } + + getByPost(post: Post): Observable { + return this.http + .get(`${this.url}/?postId=${post.id}`) + .pipe(catchError(this.handleError)); + } + + private handleError({ status }: HttpErrorResponse) { + return throwError( + () => `${status}: ERROR - Unable to fetch posts` + ); + } +} \ No newline at end of file diff --git a/src/app/apis/post.service.spec.ts b/src/app/apis/post.service.spec.ts new file mode 100644 index 0000000..4d43b46 --- /dev/null +++ b/src/app/apis/post.service.spec.ts @@ -0,0 +1,52 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; + +import { PostService } from './post.service'; +import { environment } from 'src/environments/environment'; +import { Post } from '../models/post.model'; + +describe('PostsService', () => { + let service: PostService; + let httpMock: HttpTestingController; + const apiUrl = environment.apiUrl + '/posts'; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [PostService] + }); + service = TestBed.inject(PostService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should fetch all posts', () => { + const dummyPosts: Post[] = [ + { id: 1, userId: 1, title: 'Test 1', body: 'Body 1' }, + { id: 2, userId: 1, title: 'Test 2', body: 'Body 2' } + ]; + service.getAll().subscribe(posts => { + expect(posts).toEqual(dummyPosts); + }); + const req = httpMock.expectOne(apiUrl); + expect(req.request.method).toBe('GET'); + req.flush(dummyPosts); + }); + + it('should fetch post by id', () => { + const dummyPost: Post = { id: 1, userId: 1, title: 'Test', body: 'Body' }; + service.getById(1).subscribe(post => { + expect(post).toEqual(dummyPost); + }); + const req = httpMock.expectOne(`${apiUrl}/1`); + expect(req.request.method).toBe('GET'); + req.flush(dummyPost); + }); +}); diff --git a/src/app/apis/post.service.ts b/src/app/apis/post.service.ts new file mode 100644 index 0000000..d7727da --- /dev/null +++ b/src/app/apis/post.service.ts @@ -0,0 +1,32 @@ +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { environment } from 'src/environments/environment'; +import { catchError, Observable, throwError } from 'rxjs'; +import { Post } from '../models/post.model'; + +@Injectable({ + providedIn: 'root' +}) +export class PostService { + private url = environment.apiUrl + '/posts'; + + constructor(private http: HttpClient) { } + + getAll(): Observable { + return this.http + .get(this.url) + .pipe(catchError(this.handleError)); + } + + getById(id: number): Observable { + return this.http + .get(`${this.url}/${id}`) + .pipe(catchError(this.handleError)); + } + + private handleError({ status }: HttpErrorResponse) { + return throwError( + () => `${status}: ERROR - Unable to fetch posts` + ); + } +} diff --git a/src/app/app-routes.ts b/src/app/app-routes.ts index a1cf04d..09ca3b7 100644 --- a/src/app/app-routes.ts +++ b/src/app/app-routes.ts @@ -1,3 +1,31 @@ import { Routes } from '@angular/router'; +import { provideEffects } from '@ngrx/effects'; +import { provideState } from '@ngrx/store'; +import { postsFeature } from './store/post/posts.reducer'; +import { commentsFeature } from './store/comment/comments.reducer'; +import { PostsEffects } from './store/post/posts.effects'; +import { CommentsEffects } from './store/comment/comments.effects'; +import { GuestbookPageComponent } from './features/guestbook/guestbook-page/guestbook-page.component'; +import { guestbookFeature } from './store/guestbook/guestbook.reducer'; +import { GuestbookEffects } from './store/guestbook/guestbook.effects'; -export const appRoutes: Routes = []; +export const routes: Routes = [ + { + path: 'guestbook', + component: GuestbookPageComponent, + providers: [ + provideState(guestbookFeature), + provideEffects(GuestbookEffects), + ], + }, + { + path: '', + loadChildren: () => + import('./features/posts/posts.routes').then((mod) => mod.routes), + providers: [ + provideState(postsFeature), + provideState(commentsFeature), + provideEffects(PostsEffects, CommentsEffects), + ], + }, +]; diff --git a/src/app/app.component.html b/src/app/app.component.html index f28834e..fbfafdf 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,488 +1,11 @@ - - - - - -
- - -
- - - Rocket Ship - - - - - - - - - - {{ title }} app is running! - - - Rocket Ship Smoke - - - -
- - -

Resources

-

Here are some links to help you get started:

- - - - -

Next Steps

-

What do you want to do next with your app?

- - - -
- - - - - - - - - - - -
- - -
-@switch (selection.value) { - @default { -
ng generate component xyz
-} - @case ('material') { -
ng add @angular/material
-} - @case ('pwa') { -
ng add @angular/pwa
-} - @case ('dependency') { -
ng add _____
-} - @case ('test') { -
ng test
-} - @case ('build') { -
ng build
-} - } -
- - - - - - - - - Gray Clouds Background - - - -
- - - - - - - - - - + + + +
+
+ +
+
\ No newline at end of file diff --git a/src/app/app.component.scss b/src/app/app.component.scss index e69de29..1de8dd7 100644 --- a/src/app/app.component.scss +++ b/src/app/app.component.scss @@ -0,0 +1,11 @@ +.posts-container { + width: 80%; + margin: auto; + display: flex; + flex-direction: column; + align-items: center; +} + +mat-toolbar { + margin-bottom: 24px; +} \ No newline at end of file diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index a59d261..d2b642b 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -5,7 +5,7 @@ import { AppComponent } from './app.component'; describe('AppComponent', () => { beforeEach(() => TestBed.configureTestingModule({ imports: [RouterTestingModule, AppComponent] -})); + })); it('should create the app', () => { const fixture = TestBed.createComponent(AppComponent); @@ -13,16 +13,9 @@ describe('AppComponent', () => { expect(app).toBeTruthy(); }); - it(`should have as title 'angular-template'`, () => { + it(`should have as title 'Training Angular Project'`, () => { const fixture = TestBed.createComponent(AppComponent); const app = fixture.componentInstance; - expect(app.title).toEqual('angular-template'); - }); - - it('should render title', () => { - const fixture = TestBed.createComponent(AppComponent); - fixture.detectChanges(); - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('.content span')?.textContent).toContain('angular-template app is running!'); + expect(app.title).toEqual('Training Angular Project'); }); }); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index c53dbc9..d8ac2f8 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,13 +1,23 @@ import { Component } from '@angular/core'; -import { RouterOutlet } from '@angular/router'; +import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; +import { MatCardModule } from '@angular/material/card'; +import { MatButtonModule } from '@angular/material/button'; +import { MatToolbarModule } from '@angular/material/toolbar'; @Component({ selector: 'app-root', - imports: [RouterOutlet], + imports: [ + RouterOutlet, + RouterLinkActive, + RouterLink, + MatCardModule, + MatButtonModule, + MatToolbarModule + ], templateUrl: './app.component.html', styleUrls: ['./app.component.scss'], }) export class AppComponent { - title = 'angular-template'; + title = 'Training Angular Project'; } diff --git a/src/app/features/guestbook/guestbook-author/guestbook-author.component.html b/src/app/features/guestbook/guestbook-author/guestbook-author.component.html new file mode 100644 index 0000000..ebd6f7b --- /dev/null +++ b/src/app/features/guestbook/guestbook-author/guestbook-author.component.html @@ -0,0 +1,15 @@ +
+ +
+
+ Name: + {{ authorName() }} +
+ +
+ Email: + {{ email() }} +
+
+
+
\ No newline at end of file diff --git a/src/app/features/guestbook/guestbook-author/guestbook-author.component.scss b/src/app/features/guestbook/guestbook-author/guestbook-author.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/app/features/guestbook/guestbook-author/guestbook-author.component.spec.ts b/src/app/features/guestbook/guestbook-author/guestbook-author.component.spec.ts new file mode 100644 index 0000000..c13dadc --- /dev/null +++ b/src/app/features/guestbook/guestbook-author/guestbook-author.component.spec.ts @@ -0,0 +1,46 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA } from '@angular/material/dialog'; + +import { GuestbookAuthorComponent } from './guestbook-author.component'; +import { Author } from 'src/app/models/author.model'; + +describe('GuestbookAuthorComponent', () => { + let component: GuestbookAuthorComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [GuestbookAuthorComponent], + providers: [ + { provide: MAT_DIALOG_DATA, useValue: {} } + ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(GuestbookAuthorComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should init', () => { + const mockAuthor: Author = { name: 'John', email: 'john@' }; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [GuestbookAuthorComponent], + providers: [ + { provide: MAT_DIALOG_DATA, useValue: mockAuthor } + ] + }); + + const testFixture = TestBed.createComponent(GuestbookAuthorComponent); + const testComponent = testFixture.componentInstance; + + expect(testComponent.authorName()).toBe('John'); + expect(testComponent.email()).toBe('john@'); + }); +}); diff --git a/src/app/features/guestbook/guestbook-author/guestbook-author.component.ts b/src/app/features/guestbook/guestbook-author/guestbook-author.component.ts new file mode 100644 index 0000000..97b826f --- /dev/null +++ b/src/app/features/guestbook/guestbook-author/guestbook-author.component.ts @@ -0,0 +1,22 @@ +import { Component, Inject, signal } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; +import { Author } from 'src/app/models/author.model'; + +@Component({ + selector: 'app-guestbook-author', + imports: [MatDialogModule], + templateUrl: './guestbook-author.component.html', + styleUrl: './guestbook-author.component.scss' +}) +export class GuestbookAuthorComponent { + readonly authorName = signal('') + readonly email = signal('') + + constructor(@Inject(MAT_DIALOG_DATA) public data: Author) { + if (data) { + this.authorName.set(data.name || ''); + this.email.set(data.email || ''); + } + } + +} diff --git a/src/app/features/guestbook/guestbook-edit/guestbook-edit.component.html b/src/app/features/guestbook/guestbook-edit/guestbook-edit.component.html new file mode 100644 index 0000000..6f31799 --- /dev/null +++ b/src/app/features/guestbook/guestbook-edit/guestbook-edit.component.html @@ -0,0 +1,42 @@ +

Guest Book Entry

+ +
+ + Name + + + Name is required + + + + + Email + + + Email is required + + + Please enter a valid email + + + + + Message + + + Message is required + + + Message must be at least 20 characters long + + +
+
+
+ + + + +
\ No newline at end of file diff --git a/src/app/features/guestbook/guestbook-edit/guestbook-edit.component.scss b/src/app/features/guestbook/guestbook-edit/guestbook-edit.component.scss new file mode 100644 index 0000000..9d9c67f --- /dev/null +++ b/src/app/features/guestbook/guestbook-edit/guestbook-edit.component.scss @@ -0,0 +1,12 @@ +.guest-book-entry-button { + display: flex; + justify-content: end; + padding-bottom: 16px; + padding-right: 16px; +} + +.guest-post-form { + display: flex; + flex-direction: column; + gap: 16px; +} \ No newline at end of file diff --git a/src/app/features/guestbook/guestbook-edit/guestbook-edit.component.spec.ts b/src/app/features/guestbook/guestbook-edit/guestbook-edit.component.spec.ts new file mode 100644 index 0000000..c18f0b6 --- /dev/null +++ b/src/app/features/guestbook/guestbook-edit/guestbook-edit.component.spec.ts @@ -0,0 +1,55 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { MatDialogRef } from '@angular/material/dialog'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { GuestbookEditComponent } from './guestbook-edit.component'; +import { GuestbookEntryForm } from 'src/app/models/guestbook.model'; + +describe('GuestbookEditComponent', () => { + let component: GuestbookEditComponent; + let fixture: ComponentFixture; + let mockDialogRef: jasmine.SpyObj>; + + beforeEach(async () => { + mockDialogRef = jasmine.createSpyObj('MatDialogRef', ['close']); + + await TestBed.configureTestingModule({ + imports: [ + GuestbookEditComponent, + ReactiveFormsModule, + NoopAnimationsModule + ], + providers: [ + { provide: MatDialogRef, useValue: mockDialogRef } + ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(GuestbookEditComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should submit valid form', () => { + const formData: GuestbookEntryForm = { + name: 'John Doe', + email: 'john@example.com', + message: 'This is a test message This is a test message This is a test message' + }; + + component.guestBookForm.patchValue(formData); + component.onSubmit(); + + expect(mockDialogRef.close).toHaveBeenCalledWith( + jasmine.objectContaining({ + ...formData, + createdAt: jasmine.any(Date) + }) + ); + }); +}); diff --git a/src/app/features/guestbook/guestbook-edit/guestbook-edit.component.ts b/src/app/features/guestbook/guestbook-edit/guestbook-edit.component.ts new file mode 100644 index 0000000..d0d59ff --- /dev/null +++ b/src/app/features/guestbook/guestbook-edit/guestbook-edit.component.ts @@ -0,0 +1,49 @@ +import { NgIf } from '@angular/common'; +import { Component } from '@angular/core'; +import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; +import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatButtonModule } from '@angular/material/button'; + +@Component({ + selector: 'app-guestbook-edit', + imports: [ + NgIf, + MatDialogModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule, + ReactiveFormsModule + ], + templateUrl: './guestbook-edit.component.html', + styleUrl: './guestbook-edit.component.scss' +}) +export class GuestbookEditComponent { + guestBookForm: FormGroup; + + constructor( + private guestPostEntryForm: FormBuilder, + private guestBookEntryDialogRef: MatDialogRef, + ) { + this.guestBookForm = this.guestPostEntryForm.group({ + name: ['', Validators.required], + email: ['', [Validators.required, Validators.email]], + message: ['', [Validators.required, Validators.minLength(20)]] + }); + } + + onSubmit(): void { + if (this.guestBookForm.valid) { + const entry = { + ...this.guestBookForm.value, + createdAt: new Date() + }; + this.guestBookEntryDialogRef.close(entry); + } + } + + onCancel(): void { + this.guestBookEntryDialogRef.close(); + } +} diff --git a/src/app/features/guestbook/guestbook-list/guestbook-list.component.html b/src/app/features/guestbook/guestbook-list/guestbook-list.component.html new file mode 100644 index 0000000..cd5b5d4 --- /dev/null +++ b/src/app/features/guestbook/guestbook-list/guestbook-list.component.html @@ -0,0 +1,16 @@ +
+
+ + + + {{ gPost.author.name }} + + + +

{{ gPost.message }}

+
+
+
+
\ No newline at end of file diff --git a/src/app/features/guestbook/guestbook-list/guestbook-list.component.scss b/src/app/features/guestbook/guestbook-list/guestbook-list.component.scss new file mode 100644 index 0000000..9099e7b --- /dev/null +++ b/src/app/features/guestbook/guestbook-list/guestbook-list.component.scss @@ -0,0 +1,8 @@ +.guest-post-item { + padding: 8px; +} + +.clickable-author { + cursor: pointer; + color: var(--primary-color); +} \ No newline at end of file diff --git a/src/app/features/guestbook/guestbook-list/guestbook-list.component.spec.ts b/src/app/features/guestbook/guestbook-list/guestbook-list.component.spec.ts new file mode 100644 index 0000000..3e70731 --- /dev/null +++ b/src/app/features/guestbook/guestbook-list/guestbook-list.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { GuestbookListComponent } from './guestbook-list.component'; + +describe('GuestbookListComponent', () => { + let component: GuestbookListComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [GuestbookListComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(GuestbookListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/guestbook/guestbook-list/guestbook-list.component.ts b/src/app/features/guestbook/guestbook-list/guestbook-list.component.ts new file mode 100644 index 0000000..c7d504c --- /dev/null +++ b/src/app/features/guestbook/guestbook-list/guestbook-list.component.ts @@ -0,0 +1,22 @@ +import { Component, Input } from '@angular/core'; +import { GuestbookEntry } from '../../../models/guestbook.model'; +import { NgFor } from '@angular/common'; +import { MatCardModule } from '@angular/material/card'; +import { Author } from 'src/app/models/author.model'; +import { GuestbookAuthorDialogService } from '../services/guestbook-author-dialog.service'; + +@Component({ + selector: 'app-guestbook-list', + imports: [NgFor, MatCardModule], + templateUrl: './guestbook-list.component.html', + styleUrl: './guestbook-list.component.scss' +}) +export class GuestbookListComponent { + @Input() guestPosts: GuestbookEntry[] | null = []; + + constructor(private authorDialogService: GuestbookAuthorDialogService) { } + + openAuthorDialog(author: Author): void { + this.authorDialogService.openAuthorDialog(author); + } +} diff --git a/src/app/features/guestbook/guestbook-page/guestbook-page.component.html b/src/app/features/guestbook/guestbook-page/guestbook-page.component.html new file mode 100644 index 0000000..b293128 --- /dev/null +++ b/src/app/features/guestbook/guestbook-page/guestbook-page.component.html @@ -0,0 +1,18 @@ +

Guest Book

+ +
+ Error: {{ errorMessage() }} +
+ +
+ +
+
+
+ +
+
+ +Loading... \ No newline at end of file diff --git a/src/app/features/guestbook/guestbook-page/guestbook-page.component.scss b/src/app/features/guestbook/guestbook-page/guestbook-page.component.scss new file mode 100644 index 0000000..3dcd486 --- /dev/null +++ b/src/app/features/guestbook/guestbook-page/guestbook-page.component.scss @@ -0,0 +1,3 @@ +.guest-book-add-entry { + padding-bottom: 16px; +} \ No newline at end of file diff --git a/src/app/features/guestbook/guestbook-page/guestbook-page.component.spec.ts b/src/app/features/guestbook/guestbook-page/guestbook-page.component.spec.ts new file mode 100644 index 0000000..3919df7 --- /dev/null +++ b/src/app/features/guestbook/guestbook-page/guestbook-page.component.spec.ts @@ -0,0 +1,49 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { GuestbookPageComponent } from './guestbook-page.component'; +import { provideMockStore } from '@ngrx/store/testing'; +import { selectGuestbookEntries } from 'src/app/store/guestbook/guestbook.selector'; + +describe('GuestbookPageComponent', () => { + let component: GuestbookPageComponent; + let fixture: ComponentFixture; + + const mockGuestbookEntries = [ + { id: 1, author: {name: 'Test Guest 1'}, message: 'Test Message 1' }, + { id: 2, author: {name: 'Test Guest 2'}, message: 'Test Message 2' } + ]; + + const initialState = { + guestbook: { + loading: false, + errorMessage: '', + ids: [1, 2], + entities: { + 1: mockGuestbookEntries[0], + 2: mockGuestbookEntries[1] + } + } + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [GuestbookPageComponent], + providers: [ + provideMockStore({ + initialState, + selectors: [ + { selector: selectGuestbookEntries, value: mockGuestbookEntries } + ] + }) + ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(GuestbookPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/guestbook/guestbook-page/guestbook-page.component.ts b/src/app/features/guestbook/guestbook-page/guestbook-page.component.ts new file mode 100644 index 0000000..1f8e801 --- /dev/null +++ b/src/app/features/guestbook/guestbook-page/guestbook-page.component.ts @@ -0,0 +1,35 @@ +import { NgIf } from '@angular/common'; +import { Component } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { MatButtonModule } from '@angular/material/button'; +import { GuestbookListComponent } from '../guestbook-list/guestbook-list.component'; +import { selectGuestbookEntries, selectGuestbookLoading, selectGuestbookErrorMessage } from 'src/app/store/guestbook/guestbook.selector'; +import { GuestbookPageActions } from 'src/app/store/guestbook/guestbook.actions'; +import { MatCardModule } from '@angular/material/card'; +import { GuestbookDialogService } from '../services/guestbook-dialog.service'; + +@Component({ + selector: 'app-guestbook-page', + imports: [NgIf, MatCardModule, MatButtonModule, GuestbookListComponent], + templateUrl: './guestbook-page.component.html', + styleUrl: './guestbook-page.component.scss' +}) +export class GuestbookPageComponent { + guestPosts = this.store.selectSignal(selectGuestbookEntries); + loading = this.store.selectSignal(selectGuestbookLoading); + errorMessage = this.store.selectSignal(selectGuestbookErrorMessage); + + constructor( + private store: Store, + private guestbookDialogService: GuestbookDialogService + ) { } + + openGuestBookDialog(): void { + this.guestbookDialogService.openGuestBookDialog().subscribe((result) => { + if (result) { + const entry = this.guestbookDialogService.createGuestbookEntry(result); + this.store.dispatch(GuestbookPageActions.addGuestbookEntry({ entry })); + } + }); + } +} diff --git a/src/app/features/guestbook/services/guestbook-author-dialog.service.spec.ts b/src/app/features/guestbook/services/guestbook-author-dialog.service.spec.ts new file mode 100644 index 0000000..8be1e7c --- /dev/null +++ b/src/app/features/guestbook/services/guestbook-author-dialog.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { GuestbookAuthorDialogService } from './guestbook-author-dialog.service'; + +describe('GuestbookAuthorDialogService', () => { + let service: GuestbookAuthorDialogService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(GuestbookAuthorDialogService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/features/guestbook/services/guestbook-author-dialog.service.ts b/src/app/features/guestbook/services/guestbook-author-dialog.service.ts new file mode 100644 index 0000000..f5ce87c --- /dev/null +++ b/src/app/features/guestbook/services/guestbook-author-dialog.service.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { GuestbookAuthorComponent } from '../guestbook-author/guestbook-author.component'; +import { Author } from 'src/app/models/author.model'; + +@Injectable({ + providedIn: 'root' +}) +export class GuestbookAuthorDialogService { + + constructor(private dialog: MatDialog) { } + + openAuthorDialog(author: Author): void { + this.dialog.open(GuestbookAuthorComponent, { + data: author + }); + } +} diff --git a/src/app/features/guestbook/services/guestbook-dialog.service.spec.ts b/src/app/features/guestbook/services/guestbook-dialog.service.spec.ts new file mode 100644 index 0000000..bdd8185 --- /dev/null +++ b/src/app/features/guestbook/services/guestbook-dialog.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; +import { GuestbookDialogService } from './guestbook-dialog.service'; + + +describe('GuestbookDialogService', () => { + let service: GuestbookDialogService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(GuestbookDialogService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/features/guestbook/services/guestbook-dialog.service.ts b/src/app/features/guestbook/services/guestbook-dialog.service.ts new file mode 100644 index 0000000..3911746 --- /dev/null +++ b/src/app/features/guestbook/services/guestbook-dialog.service.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { Observable } from 'rxjs'; +import { GuestbookEditComponent } from '../guestbook-edit/guestbook-edit.component'; +import { GuestbookEntry, GuestbookEntryForm } from 'src/app/models/guestbook.model'; + +@Injectable({ + providedIn: 'root' +}) +export class GuestbookDialogService { + + constructor(private dialog: MatDialog) { } + + openGuestBookDialog(): Observable { + const guestBookDialogRef = this.dialog.open(GuestbookEditComponent, { + width: '500px', + disableClose: true + }); + + return guestBookDialogRef.afterClosed(); + } + + createGuestbookEntry(formResult: GuestbookEntryForm): GuestbookEntry { + return { + id: Math.floor(Math.random() * 1000), + author: { + name: formResult.name, + email: formResult.email + }, + message: formResult.message, + createdAt: new Date() + }; + } +} diff --git a/src/app/features/guestbook/services/guestbook.service.spec.ts b/src/app/features/guestbook/services/guestbook.service.spec.ts new file mode 100644 index 0000000..828d92c --- /dev/null +++ b/src/app/features/guestbook/services/guestbook.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { GuestbookService } from './guestbook.service'; + +describe('GuestbookService', () => { + let service: GuestbookService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(GuestbookService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/features/guestbook/services/guestbook.service.ts b/src/app/features/guestbook/services/guestbook.service.ts new file mode 100644 index 0000000..f05f3de --- /dev/null +++ b/src/app/features/guestbook/services/guestbook.service.ts @@ -0,0 +1,39 @@ +import { Injectable } from '@angular/core'; +import { Observable, of } from 'rxjs'; +import { GuestbookEntry } from 'src/app/models/guestbook.model'; + +@Injectable({ + providedIn: 'root' +}) +export class GuestbookService { + private entries: GuestbookEntry[] = [ + { + id: 876, + author: { + name: 'John Doe', + email: 'john@example.com' + }, + message: 'This is a test message. This is a test message. This is a test message. This is a test message.', + createdAt: new Date() + }, + { + id: 343, + author: { + name: 'Jane Smith', + email: 'jane@example.com' + }, + message: 'This is a test message. This is a test message. This is a test message. This is a test message.', + createdAt: new Date() + } + ]; + + loadEntries(): Observable { + return of(this.entries); + } + + add(entry: GuestbookEntry): Observable { + this.entries = [...this.entries, entry]; + return of(entry); + } + +} diff --git a/src/app/features/posts/post-detail/post-detail.component.html b/src/app/features/posts/post-detail/post-detail.component.html new file mode 100644 index 0000000..c9e2b97 --- /dev/null +++ b/src/app/features/posts/post-detail/post-detail.component.html @@ -0,0 +1,50 @@ +
+ +
+ +
+ + + {{ post.title }} + + +

{{ post.body }}

+
+
+
+ +
+ + + Comments ({{ comments?.length }}) + + + + +
+ + + {{ comment.name }} + + + {{ comment.email }} + + + +

{{ comment.body }}

+
+
+
+
+
+
+
+ + +

No comments yet.

+
+ +Loading comments... \ No newline at end of file diff --git a/src/app/features/posts/post-detail/post-detail.component.scss b/src/app/features/posts/post-detail/post-detail.component.scss new file mode 100644 index 0000000..8cea088 --- /dev/null +++ b/src/app/features/posts/post-detail/post-detail.component.scss @@ -0,0 +1,27 @@ +.post-card-container { + margin-bottom: 32px; + font-size: medium; + white-space: pre-line; + padding-top: 16px; +} + +.comments-card-container { + margin-top: 12px; + font-size: small; + + .comment-content { + word-break: break-word; + white-space: pre-line; + background: none; + } + + .comment-card { + margin-top: 12px; + margin-bottom: 12px; + } + + .comment-header { + padding: 8px 16px; + border-bottom: 1px solid #e0e0e0; + } +} \ No newline at end of file diff --git a/src/app/features/posts/post-detail/post-detail.component.spec.ts b/src/app/features/posts/post-detail/post-detail.component.spec.ts new file mode 100644 index 0000000..cc2240a --- /dev/null +++ b/src/app/features/posts/post-detail/post-detail.component.spec.ts @@ -0,0 +1,43 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { PostsDetailComponent } from './post-detail.component'; +import { Router } from '@angular/router'; +import { provideMockStore } from '@ngrx/store/testing'; + +describe('PostsDetailComponent', () => { + let component: PostsDetailComponent; + let fixture: ComponentFixture; + let router: jasmine.SpyObj; + + const initialState = { + comments: { + loading: false + } + }; + + beforeEach(async () => { + const routerSpy = jasmine.createSpyObj('Router', ['navigate']); + + await TestBed.configureTestingModule({ + imports: [PostsDetailComponent], + providers: [ + provideMockStore({ initialState }), + { provide: Router, useValue: routerSpy } + ] + }).compileComponents(); + + router = TestBed.inject(Router) as jasmine.SpyObj; + fixture = TestBed.createComponent(PostsDetailComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should navigate back when goBack is called', () => { + component.goBack(); + expect(router.navigate).toHaveBeenCalledWith(['/']); + }); + +}); diff --git a/src/app/features/posts/post-detail/post-detail.component.ts b/src/app/features/posts/post-detail/post-detail.component.ts new file mode 100644 index 0000000..3d234c1 --- /dev/null +++ b/src/app/features/posts/post-detail/post-detail.component.ts @@ -0,0 +1,35 @@ +import { Component, Input } from '@angular/core'; +import { MatCardModule } from '@angular/material/card'; +import { NgFor, NgIf } from '@angular/common'; +import { Store } from '@ngrx/store'; +import { MatIconModule } from '@angular/material/icon'; +import { MatButton } from '@angular/material/button'; +import { Router } from '@angular/router'; +import { selectCommentsLoading } from 'src/app/store/comment/comments.selectors'; +import { Comment } from 'src/app/models/comment.model'; +import { Post } from 'src/app/models/post.model'; + +@Component({ + selector: 'app-posts-detail', + standalone: true, + imports: [ + MatCardModule, + NgIf, + NgFor, + MatIconModule, + MatButton + ], + templateUrl: './post-detail.component.html', + styleUrl: './post-detail.component.scss' +}) +export class PostsDetailComponent { + @Input() post: Post | undefined; + @Input() comments: Comment[] | null = []; + loadingComments = this.store.selectSignal(selectCommentsLoading); + + constructor(private store: Store, private router: Router) { } + + goBack() { + this.router.navigate(['/']); + } +} diff --git a/src/app/features/posts/post-page/post-page.component.html b/src/app/features/posts/post-page/post-page.component.html new file mode 100644 index 0000000..0b2c380 --- /dev/null +++ b/src/app/features/posts/post-page/post-page.component.html @@ -0,0 +1,17 @@ +

Post Details

+ +
+ Error: {{ errorMessage() }} +
+ +
+ + + + +
+ +Loading... + +
This post does not exist.
+
\ No newline at end of file diff --git a/src/app/features/posts/post-page/post-page.component.scss b/src/app/features/posts/post-page/post-page.component.scss new file mode 100644 index 0000000..ca9feb4 --- /dev/null +++ b/src/app/features/posts/post-page/post-page.component.scss @@ -0,0 +1,3 @@ +.post-card-container { + margin-bottom: 32px; +} \ No newline at end of file diff --git a/src/app/features/posts/post-page/post-page.component.spec.ts b/src/app/features/posts/post-page/post-page.component.spec.ts new file mode 100644 index 0000000..ca184ef --- /dev/null +++ b/src/app/features/posts/post-page/post-page.component.spec.ts @@ -0,0 +1,48 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { PostPageComponent } from './post-page.component'; +import { provideMockStore } from '@ngrx/store/testing'; +import { selectComments } from 'src/app/store/comment/comments.selectors'; +import { selectPostById, selectPostsErrorMessage, selectPostsLoading } from 'src/app/store/post/posts.selectors'; + + +describe('PostPageComponent', () => { + let component: PostPageComponent; + let fixture: ComponentFixture; + + const initialState = { + posts: { + loading: false, + errorMessage: '', + entities: [], + ids: [] + }, + comments: { + comments: [] + } + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PostPageComponent], + providers: [ + provideMockStore({ + initialState, + selectors: [ + { selector: selectPostsLoading, value: false }, + { selector: selectPostsErrorMessage, value: '' }, + { selector: selectPostById, value: null }, + { selector: selectComments, value: [] } + ] + }) + ] + }).compileComponents(); + + fixture = TestBed.createComponent(PostPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/posts/post-page/post-page.component.ts b/src/app/features/posts/post-page/post-page.component.ts new file mode 100644 index 0000000..c297420 --- /dev/null +++ b/src/app/features/posts/post-page/post-page.component.ts @@ -0,0 +1,30 @@ +import { Component, effect } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { NgIf } from '@angular/common'; +import { selectComments } from 'src/app/store/comment/comments.selectors'; +import { CommentsComponentActions } from 'src/app/store/comment/comments.actions'; +import { PostsDetailComponent } from '../post-detail/post-detail.component'; +import { selectPostById, selectPostsErrorMessage, selectPostsLoading } from 'src/app/store/post/posts.selectors'; + +@Component({ + selector: 'app-post-page', + imports: [NgIf, PostsDetailComponent], + templateUrl: './post-page.component.html', + styleUrl: './post-page.component.scss' +}) +export class PostPageComponent { + loading = this.store.selectSignal(selectPostsLoading); + errorMessage = this.store.selectSignal(selectPostsErrorMessage); + post = this.store.selectSignal(selectPostById); + comments = this.store.selectSignal(selectComments); + + + constructor(private store: Store) { + effect(() => { + const currentPost = this.post(); + if (currentPost) { + this.store.dispatch(CommentsComponentActions.loadPostComments({ post: currentPost })); + } + }); + } +} diff --git a/src/app/features/posts/posts-list/posts-list.component.html b/src/app/features/posts/posts-list/posts-list.component.html new file mode 100644 index 0000000..a31f603 --- /dev/null +++ b/src/app/features/posts/posts-list/posts-list.component.html @@ -0,0 +1,17 @@ +
+
+ + + {{ post.title }} + + +

{{ post.body }}

+
+ + + +
+
+
\ No newline at end of file diff --git a/src/app/features/posts/posts-list/posts-list.component.scss b/src/app/features/posts/posts-list/posts-list.component.scss new file mode 100644 index 0000000..ee78570 --- /dev/null +++ b/src/app/features/posts/posts-list/posts-list.component.scss @@ -0,0 +1,13 @@ +.posts-list { + display: flex; + flex-wrap: wrap; + gap: 16px; +} + +.post-item { + width: 260px; +} + +.post-content { + padding: 16px; +} \ No newline at end of file diff --git a/src/app/features/posts/posts-list/posts-list.component.spec.ts b/src/app/features/posts/posts-list/posts-list.component.spec.ts new file mode 100644 index 0000000..00dd16e --- /dev/null +++ b/src/app/features/posts/posts-list/posts-list.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { PostsListComponent } from './posts-list.component'; + +describe('PostsListComponent', () => { + let component: PostsListComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PostsListComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(PostsListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/posts/posts-list/posts-list.component.ts b/src/app/features/posts/posts-list/posts-list.component.ts new file mode 100644 index 0000000..3df9e55 --- /dev/null +++ b/src/app/features/posts/posts-list/posts-list.component.ts @@ -0,0 +1,21 @@ +import { Component, Input } from '@angular/core'; +import { NgFor } from '@angular/common'; +import { MatCardModule } from '@angular/material/card'; +import { MatButton } from '@angular/material/button'; +import { RouterLink } from '@angular/router'; +import { Post } from 'src/app/models/post.model'; + +@Component({ + selector: 'app-posts-list', + imports: [ + NgFor, + MatCardModule, + MatButton, + RouterLink + ], + templateUrl: './posts-list.component.html', + styleUrl: './posts-list.component.scss' +}) +export class PostsListComponent { + @Input() posts: Post[] | null = []; +} diff --git a/src/app/features/posts/posts-page/posts-page.component.html b/src/app/features/posts/posts-page/posts-page.component.html new file mode 100644 index 0000000..33a85b5 --- /dev/null +++ b/src/app/features/posts/posts-page/posts-page.component.html @@ -0,0 +1,13 @@ +

Posts

+ +
+ Error: {{ errorMessage() }} +
+ +
+
+ +
+
+ +Loading... \ No newline at end of file diff --git a/src/app/features/posts/posts-page/posts-page.component.scss b/src/app/features/posts/posts-page/posts-page.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/app/features/posts/posts-page/posts-page.component.spec.ts b/src/app/features/posts/posts-page/posts-page.component.spec.ts new file mode 100644 index 0000000..b44109e --- /dev/null +++ b/src/app/features/posts/posts-page/posts-page.component.spec.ts @@ -0,0 +1,89 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { PostsPageComponent } from './posts-page.component'; +import { MockStore, provideMockStore } from '@ngrx/store/testing'; +import { selectPosts, selectPostsErrorMessage, selectPostsLoading } from 'src/app/store/post/posts.selectors'; + +describe('PostsPageComponent', () => { + let component: PostsPageComponent; + let fixture: ComponentFixture; + let store: MockStore; + + const mockPosts = [ + { id: 1, title: 'Test Post 1', body: 'Test Body 1', userId: 1 }, + { id: 2, title: 'Test Post 2', body: 'Test Body 2', userId: 1 } + ]; + + const initialState = { + posts: { + loading: false, + errorMessage: '', + ids: [1, 2], + entities: { + 1: mockPosts[0], + 2: mockPosts[1] + } + } + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + PostsPageComponent, + RouterTestingModule + ], + providers: [ + provideMockStore({ + initialState, + selectors: [ + { selector: selectPostsLoading, value: false }, + { selector: selectPostsErrorMessage, value: '' }, + { selector: selectPosts, value: mockPosts } + ] + }) + ] + }).compileComponents(); + + store = TestBed.inject(MockStore); + fixture = TestBed.createComponent(PostsPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should show loading state', () => { + store.overrideSelector(selectPostsLoading, true); + store.refreshState(); + fixture.detectChanges(); + + expect(component.loading()).toBe(true); + }); + + it('should show error message', () => { + const errorMessage = 'Test error'; + store.overrideSelector(selectPostsErrorMessage, errorMessage); + store.refreshState(); + fixture.detectChanges(); + + expect(component.errorMessage()).toBe(errorMessage); + }); + + it('should display posts', () => { + store.overrideSelector(selectPosts, mockPosts); + store.refreshState(); + fixture.detectChanges(); + + expect(component.posts()).toEqual(mockPosts); + }); + + it('should be empty posts', () => { + store.overrideSelector(selectPosts, []); + store.refreshState(); + fixture.detectChanges(); + + expect(component.posts()).toEqual([]); + }); +}); diff --git a/src/app/features/posts/posts-page/posts-page.component.ts b/src/app/features/posts/posts-page/posts-page.component.ts new file mode 100644 index 0000000..690e561 --- /dev/null +++ b/src/app/features/posts/posts-page/posts-page.component.ts @@ -0,0 +1,20 @@ +import { Component } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { NgIf } from '@angular/common'; +import { PostsListComponent } from '../posts-list/posts-list.component'; +import { selectPosts, selectPostsErrorMessage, selectPostsLoading } from 'src/app/store/post/posts.selectors'; + +@Component({ + selector: 'app-posts-page', + imports: [NgIf, PostsListComponent], + templateUrl: './posts-page.component.html', + styleUrl: './posts-page.component.scss' +}) +export class PostsPageComponent { + posts = this.store.selectSignal(selectPosts); + loading = this.store.selectSignal(selectPostsLoading); + errorMessage = this.store.selectSignal(selectPostsErrorMessage); + + constructor(private store: Store) { } + +} diff --git a/src/app/features/posts/posts.routes.ts b/src/app/features/posts/posts.routes.ts new file mode 100644 index 0000000..f4d513c --- /dev/null +++ b/src/app/features/posts/posts.routes.ts @@ -0,0 +1,14 @@ +import { Routes } from '@angular/router'; +import { PostsPageComponent } from './posts-page/posts-page.component'; +import { PostPageComponent } from './post-page/post-page.component'; + +export const routes: Routes = [ + { + path: 'post/:id', + component: PostPageComponent, + }, + { + path: '', + component: PostsPageComponent, + } +]; diff --git a/src/app/models/author.model.ts b/src/app/models/author.model.ts new file mode 100644 index 0000000..c38d383 --- /dev/null +++ b/src/app/models/author.model.ts @@ -0,0 +1,4 @@ +export interface Author { + name: string; + email: string; +} \ No newline at end of file diff --git a/src/app/models/comment.model.ts b/src/app/models/comment.model.ts new file mode 100644 index 0000000..5b55a25 --- /dev/null +++ b/src/app/models/comment.model.ts @@ -0,0 +1,7 @@ +export interface Comment { + id: number; + postId: number; + name: string; + email: string; + body: string; +} \ No newline at end of file diff --git a/src/app/models/guestbook.model.ts b/src/app/models/guestbook.model.ts new file mode 100644 index 0000000..9998a4d --- /dev/null +++ b/src/app/models/guestbook.model.ts @@ -0,0 +1,14 @@ +import { Author } from "./author.model"; + +export interface GuestbookEntry { + id: number; + author: Author + message: string; + createdAt: Date; +} + +export interface GuestbookEntryForm { + name: string, + email: string; + message: string; +} \ No newline at end of file diff --git a/src/app/models/post.model.ts b/src/app/models/post.model.ts new file mode 100644 index 0000000..21f1b84 --- /dev/null +++ b/src/app/models/post.model.ts @@ -0,0 +1,6 @@ +export interface Post { + id: number; + userId: number; + title: string; + body: string; +} \ No newline at end of file diff --git a/src/app/store/comment/comments.actions.ts b/src/app/store/comment/comments.actions.ts new file mode 100644 index 0000000..0bb54e6 --- /dev/null +++ b/src/app/store/comment/comments.actions.ts @@ -0,0 +1,19 @@ +import { createActionGroup, props } from "@ngrx/store"; +import { Comment } from "src/app/models/comment.model"; +import { Post } from "src/app/models/post.model"; + +export const CommentsComponentActions = createActionGroup({ + source: 'Comments Component', + events: { + 'Load Post Comments': props<{ post: Post }>(), + }, +}); + +export const CommentsAPIActions = createActionGroup({ + source: 'Comments API', + events: { + 'Load Post Comments': props<{ post: Post }>(), + 'Post Comments Loaded Success': props<{ comments: Comment[] }>(), + 'Post Comments Loaded Fail': props<{ message: string }>(), + }, +}); diff --git a/src/app/store/comment/comments.effects.ts b/src/app/store/comment/comments.effects.ts new file mode 100644 index 0000000..0e85e52 --- /dev/null +++ b/src/app/store/comment/comments.effects.ts @@ -0,0 +1,28 @@ +import { Injectable } from "@angular/core"; +import { Actions, createEffect, ofType } from "@ngrx/effects"; +import { catchError, concatMap, map, of } from "rxjs"; +import { CommentsAPIActions, CommentsComponentActions } from "./comments.actions"; +import { CommentService } from "src/app/apis/comment.service"; + +@Injectable() +export class CommentsEffects { + + constructor( + private commentsService: CommentService, + private actions$: Actions + ) { } + + loadComments$ = createEffect(() => + this.actions$.pipe( + ofType(CommentsComponentActions.loadPostComments), + concatMap(({ post }) => + this.commentsService.getByPost(post).pipe( + map((comments) => CommentsAPIActions.postCommentsLoadedSuccess({ comments })), + catchError((error) => + of(CommentsAPIActions.postCommentsLoadedFail({ message: error })) + ) + ) + ) + ) + ); +} \ No newline at end of file diff --git a/src/app/store/comment/comments.reducer.ts b/src/app/store/comment/comments.reducer.ts new file mode 100644 index 0000000..9dd3b25 --- /dev/null +++ b/src/app/store/comment/comments.reducer.ts @@ -0,0 +1,44 @@ +import { createFeature, createReducer, on } from "@ngrx/store"; +import { CommentsAPIActions, CommentsComponentActions } from "./comments.actions"; +import { createEntityAdapter, EntityAdapter, EntityState } from "@ngrx/entity"; +import { Comment } from "src/app/models/comment.model"; + +export interface CommentsState extends EntityState { + loading: boolean; + errorMessage: string; +} + +const adapter: EntityAdapter = createEntityAdapter({}); + +const initialState: CommentsState = adapter.getInitialState({ + loading: false, + errorMessage: '', +}); + +const { selectAll } = adapter.getSelectors(); +export const selectAllComments = selectAll; + +export const commentsFeature = createFeature({ + name: 'comments', + reducer: createReducer( + initialState, + on(CommentsComponentActions.loadPostComments, (state) => + adapter.setAll([], { + ...state, + loading: true, + errorMessage: '', + })), + on(CommentsAPIActions.postCommentsLoadedSuccess, (state, { comments }) => + adapter.setAll(comments, { + ...state, + loading: false, + comments: comments, + })), + on(CommentsAPIActions.postCommentsLoadedFail, (state, { message }) => + adapter.setAll([], { + ...state, + errorMessage: message, + loading: false, + })), + ), +}); \ No newline at end of file diff --git a/src/app/store/comment/comments.selectors.ts b/src/app/store/comment/comments.selectors.ts new file mode 100644 index 0000000..022e93e --- /dev/null +++ b/src/app/store/comment/comments.selectors.ts @@ -0,0 +1,19 @@ +import { createFeatureSelector, createSelector } from "@ngrx/store"; +import * as fromComments from './comments.reducer'; + +export const selectCommentsState = createFeatureSelector('comments'); + +export const selectCommentsLoading = createSelector( + selectCommentsState, + ({ loading }) => loading +); + +export const selectCommentsErrorMessage = createSelector( + selectCommentsState, + ({ errorMessage }) => errorMessage +); + +export const selectComments = createSelector( + selectCommentsState, + fromComments.selectAllComments +); diff --git a/src/app/store/guestbook/guestbook.actions.ts b/src/app/store/guestbook/guestbook.actions.ts new file mode 100644 index 0000000..e68b07d --- /dev/null +++ b/src/app/store/guestbook/guestbook.actions.ts @@ -0,0 +1,21 @@ +import { createActionGroup, emptyProps, props } from "@ngrx/store"; +import { GuestbookEntry } from "src/app/models/guestbook.model"; + +export const GuestbookPageActions = createActionGroup({ + source: 'Guestbook Page', + events: { + 'Load Guestbook Entries': emptyProps(), + 'Add Guestbook Entry': props<{ entry: GuestbookEntry }>(), + }, +}); + +export const GuestbookAPIActions = createActionGroup({ + source: 'Guestbook API', + events: { + 'Load Guestbook Entries': emptyProps(), + 'Guestbook Entries Loaded Success': props<{ entries: GuestbookEntry[] }>(), + 'Guestbook Entries Loaded Fail': props<{ message: string }>(), + 'Guestbook Entry Added Success': props<{ entry: GuestbookEntry }>(), + 'Guestbook Entry Added Fail': props<{ message: string }>(), + }, +}); \ No newline at end of file diff --git a/src/app/store/guestbook/guestbook.effects.ts b/src/app/store/guestbook/guestbook.effects.ts new file mode 100644 index 0000000..33e3b33 --- /dev/null +++ b/src/app/store/guestbook/guestbook.effects.ts @@ -0,0 +1,43 @@ +import { Injectable } from "@angular/core"; +import { Actions, createEffect, ofType } from "@ngrx/effects"; +import { GuestbookAPIActions, GuestbookPageActions } from "./guestbook.actions"; +import { catchError, concatMap, map, of } from 'rxjs'; +import { GuestbookService } from "src/app/features/guestbook/services/guestbook.service"; + + +@Injectable() +export class GuestbookEffects { + + ngrxOnInitEffects() { + return GuestbookPageActions.loadGuestbookEntries(); + } + + constructor( + private guestbookService: GuestbookService, + private actions$: Actions + ) { } + + loadGuestbookEntries$ = createEffect(() => + this.actions$.pipe( + ofType(GuestbookPageActions.loadGuestbookEntries), + concatMap(() => + this.guestbookService.loadEntries().pipe( + map(entries => GuestbookAPIActions.guestbookEntriesLoadedSuccess({ entries })), + catchError(message => of(GuestbookAPIActions.guestbookEntriesLoadedFail({ message }))) + ) + ) + ) + ); + + addGuestbookEntry$ = createEffect(() => + this.actions$.pipe( + ofType(GuestbookPageActions.addGuestbookEntry), + concatMap(({ entry }) => + this.guestbookService.add(entry).pipe( + map((newEntry) => GuestbookAPIActions.guestbookEntryAddedSuccess({ entry: newEntry })), + catchError((error) => of(GuestbookAPIActions.guestbookEntryAddedFail({ message: error }))) + ) + ) + ) + ); +} \ No newline at end of file diff --git a/src/app/store/guestbook/guestbook.reducer.ts b/src/app/store/guestbook/guestbook.reducer.ts new file mode 100644 index 0000000..98f22de --- /dev/null +++ b/src/app/store/guestbook/guestbook.reducer.ts @@ -0,0 +1,71 @@ +import { createEntityAdapter, EntityAdapter, EntityState } from "@ngrx/entity"; +import { createFeature, createReducer, on } from "@ngrx/store"; +import { GuestbookEntry } from "src/app/models/guestbook.model"; +import { GuestbookAPIActions, GuestbookPageActions } from "./guestbook.actions"; + +export interface GuestbookState extends EntityState { + loading: boolean; + errorMessage: string; +} + +const adapter: EntityAdapter = createEntityAdapter({ + selectId, + sortComparer, +}); + +export function selectId(entry: GuestbookEntry): number { + return entry.id; +} + +export function sortComparer(a: GuestbookEntry, b: GuestbookEntry): number { + return a.createdAt < b.createdAt ? 1 : -1; +} + +const initialState: GuestbookState = adapter.getInitialState({ + loading: false, + errorMessage: '', +}); + +const { selectAll } = adapter.getSelectors(); + +export const selectAllGuestbookEntries = selectAll; + +export const guestbookFeature = createFeature({ + name: 'guestbook', + reducer: createReducer( + initialState, + on(GuestbookPageActions.loadGuestbookEntries, (state) => + adapter.setAll([], { + ...state, + loading: true, + errorMessage: '', + })), + on(GuestbookAPIActions.guestbookEntriesLoadedSuccess, (state, { entries }) => + adapter.setAll(entries, { + ...state, + loading: false, + })), + on(GuestbookAPIActions.guestbookEntriesLoadedFail, (state, { message }) => + adapter.setAll([], { + ...state, + errorMessage: message, + loading: false, + })), + on(GuestbookPageActions.addGuestbookEntry, (state) => ({ + ...state, + loading: true, + errorMessage: '', + })), + on(GuestbookAPIActions.guestbookEntryAddedSuccess, (state, { entry }) => + adapter.addOne(entry, { + ...state, + loading: false, + }) + ), + on(GuestbookAPIActions.guestbookEntryAddedFail, (state, { message }) => ({ + ...state, + loading: false, + errorMessage: message, + })), + ), +}); \ No newline at end of file diff --git a/src/app/store/guestbook/guestbook.selector.ts b/src/app/store/guestbook/guestbook.selector.ts new file mode 100644 index 0000000..967775c --- /dev/null +++ b/src/app/store/guestbook/guestbook.selector.ts @@ -0,0 +1,19 @@ +import { createFeatureSelector, createSelector } from "@ngrx/store"; +import * as fromGuestbook from './guestbook.reducer'; + +export const selectGuestbookState = createFeatureSelector('guestbook'); + +export const selectGuestbookLoading = createSelector( + selectGuestbookState, + ({ loading }) => loading +); + +export const selectGuestbookErrorMessage = createSelector( + selectGuestbookState, + ({ errorMessage }) => errorMessage +); + +export const selectGuestbookEntries = createSelector( + selectGuestbookState, + fromGuestbook.selectAllGuestbookEntries +); \ No newline at end of file diff --git a/src/app/store/post/posts.actions.ts b/src/app/store/post/posts.actions.ts new file mode 100644 index 0000000..50df0a7 --- /dev/null +++ b/src/app/store/post/posts.actions.ts @@ -0,0 +1,18 @@ +import { createActionGroup, emptyProps, props } from "@ngrx/store"; +import { Post } from "../../models/post.model"; + +export const PostsPageActions = createActionGroup({ + source: 'Posts Page', + events: { + 'Load Posts': emptyProps(), + }, +}); + +export const PostsAPIActions = createActionGroup({ + source: 'Posts API', + events: { + 'Load Posts': emptyProps(), + 'Posts Loaded Success': props<{ posts: Post[] }>(), + 'Posts Loaded Fail': props<{ message: string }>(), + }, +}); diff --git a/src/app/store/post/posts.effects.ts b/src/app/store/post/posts.effects.ts new file mode 100644 index 0000000..fa56c3f --- /dev/null +++ b/src/app/store/post/posts.effects.ts @@ -0,0 +1,32 @@ +import { Injectable } from "@angular/core"; +import { Actions, createEffect, ofType } from "@ngrx/effects"; +import { PostsAPIActions, PostsPageActions } from "./posts.actions"; +import { catchError, concatMap, map, of } from "rxjs"; +import { PostService } from "src/app/apis/post.service"; + +@Injectable() +export class PostsEffects { + + ngrxOnInitEffects() { + return PostsPageActions.loadPosts(); + } + + constructor( + private postsServiss: PostService, + private actions$: Actions + ) { } + + loadPosts$ = createEffect(() => + this.actions$.pipe( + ofType(PostsPageActions.loadPosts), + concatMap(() => + this.postsServiss.getAll().pipe( + map((posts) => PostsAPIActions.postsLoadedSuccess({ posts })), + catchError((error) => + of(PostsAPIActions.postsLoadedFail({ message: error })) + ) + ) + ) + ) + ); +} \ No newline at end of file diff --git a/src/app/store/post/posts.reducer.ts b/src/app/store/post/posts.reducer.ts new file mode 100644 index 0000000..efdae2c --- /dev/null +++ b/src/app/store/post/posts.reducer.ts @@ -0,0 +1,56 @@ +import { createFeature, createReducer, on } from "@ngrx/store"; +import { PostsAPIActions, PostsPageActions } from "./posts.actions"; +import { Post } from "../../models/post.model"; +import { createEntityAdapter, EntityAdapter, EntityState } from "@ngrx/entity"; + +export interface PostsState extends EntityState { + loading: boolean; + errorMessage: string; +} + +const adapter: EntityAdapter = createEntityAdapter({ + selectId, + sortComparer +}); + +export function selectId(post: Post): number { + return post.id; +} + +export function sortComparer(a: Post, b: Post): number { + return b.id - a.id; +} + +const initialState: PostsState = adapter.getInitialState({ + loading: false, + errorMessage: '', +}); + +const { selectEntities, selectAll } = adapter.getSelectors(); + +export const selectPostEntities = selectEntities; +export const selectAllPosts = selectAll; + +export const postsFeature = createFeature({ + name: 'posts', + reducer: createReducer( + initialState, + on(PostsPageActions.loadPosts, (state) => + adapter.setAll([], { + ...state, + loading: true, + errorMessage: '', + })), + on(PostsAPIActions.postsLoadedSuccess, (state, { posts }) => + adapter.setAll(posts, { + ...state, + loading: false, + })), + on(PostsAPIActions.postsLoadedFail, (state, { message }) => + adapter.setAll([], { + ...state, + errorMessage: message, + loading: false, + })), + ), +}); \ No newline at end of file diff --git a/src/app/store/post/posts.selectors.ts b/src/app/store/post/posts.selectors.ts new file mode 100644 index 0000000..f6e30b7 --- /dev/null +++ b/src/app/store/post/posts.selectors.ts @@ -0,0 +1,33 @@ +import { createFeatureSelector, createSelector } from "@ngrx/store"; +import { getRouterSelectors } from "@ngrx/router-store"; +import * as fromPosts from './posts.reducer'; + +export const selectPostsState = createFeatureSelector('posts'); + +export const selectPostsLoading = createSelector( + selectPostsState, + ({ loading }) => loading +); + +export const selectPostsErrorMessage = createSelector( + selectPostsState, + ({ errorMessage }) => errorMessage +); + +export const selectPosts = createSelector( + selectPostsState, + fromPosts.selectAllPosts +); + +export const selectPostsEntitites = createSelector( + selectPostsState, + fromPosts.selectPostEntities +); + +export const { selectRouteParams } = getRouterSelectors(); + +export const selectPostById = createSelector( + selectPostsEntitites, + selectRouteParams, + ((entities, { id }) => entities[id]) +); diff --git a/src/environments/environment.development.ts b/src/environments/environment.development.ts new file mode 100644 index 0000000..ac5834a --- /dev/null +++ b/src/environments/environment.development.ts @@ -0,0 +1,4 @@ +export const environment = { + production: false, + apiUrl: 'https://jsonplaceholder.typicode.com/', +}; diff --git a/src/environments/environment.ts b/src/environments/environment.ts new file mode 100644 index 0000000..a6580e6 --- /dev/null +++ b/src/environments/environment.ts @@ -0,0 +1,4 @@ +export const environment = { + production: true, + apiUrl: 'https://jsonplaceholder.typicode.com/', +}; diff --git a/src/index.html b/src/index.html index 98d504c..962c936 100644 --- a/src/index.html +++ b/src/index.html @@ -6,8 +6,10 @@ + + - + diff --git a/src/main.ts b/src/main.ts index 75a3f6a..3b9b998 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,9 +1,25 @@ import { bootstrapApplication } from '@angular/platform-browser'; import { provideRouter } from '@angular/router'; -import { appRoutes } from './app/app-routes'; +import { routes } from './app/app-routes'; import { AppComponent } from './app/app.component'; +import { provideStore } from '@ngrx/store'; +import { provideStoreDevtools } from '@ngrx/store-devtools'; +import { provideHttpClient } from '@angular/common/http'; +import { provideRouterStore, routerReducer } from '@ngrx/router-store'; +import { environment } from './environments/environment'; bootstrapApplication(AppComponent, { - providers: [provideRouter(appRoutes)] + providers: [ + provideHttpClient(), + provideStore({ + router: routerReducer, + }), + provideStoreDevtools({ + maxAge: 25, + logOnly: environment.production + }), + provideRouterStore(), + provideRouter(routes), + ] }) .catch(err => console.error(err)); diff --git a/src/styles.scss b/src/styles.scss index 90d4ee0..31d9634 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -1 +1,5 @@ /* You can add global styles to this file, and also import other style files */ +@import "@angular/material/prebuilt-themes/indigo-pink.css"; + +html, body { height: 100%; } +body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; }