From 1bf79f2c1a4ed16ee64afe2d6e9b402f85242340 Mon Sep 17 00:00:00 2001 From: Korno Date: Sun, 25 Jan 2026 17:57:52 +0200 Subject: [PATCH 1/7] refactor: add typization and signals --- .../src/app/app.component.ts | 48 +++++++------------ .../5-crud-application/src/app/app.model.ts | 14 ++++++ .../5-crud-application/src/app/app.service.ts | 32 +++++++++++++ 3 files changed, 62 insertions(+), 32 deletions(-) create mode 100644 apps/angular/5-crud-application/src/app/app.model.ts create mode 100644 apps/angular/5-crud-application/src/app/app.service.ts diff --git a/apps/angular/5-crud-application/src/app/app.component.ts b/apps/angular/5-crud-application/src/app/app.component.ts index 73ba0dc34..f3eb75db9 100644 --- a/apps/angular/5-crud-application/src/app/app.component.ts +++ b/apps/angular/5-crud-application/src/app/app.component.ts @@ -1,12 +1,13 @@ -import { HttpClient } from '@angular/common/http'; -import { Component, inject, OnInit } from '@angular/core'; -import { randText } from '@ngneat/falso'; +import { Component, inject, OnInit, signal } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; +import { todo } from './app.model'; +import { ServiceApp } from './app.service'; @Component({ imports: [], selector: 'app-root', template: ` - @for (todo of todos; track todo.id) { + @for (todo of todos(); track todo.id) { {{ todo.title }} } @@ -14,36 +15,19 @@ import { randText } from '@ngneat/falso'; styles: [], }) export class AppComponent implements OnInit { - private http = inject(HttpClient); + dataStore = inject(ServiceApp); + todos = signal([]); - todos!: any[]; - - ngOnInit(): void { - this.http - .get('https://jsonplaceholder.typicode.com/todos') - .subscribe((todos) => { - this.todos = todos; - }); + async ngOnInit() { + const todos = await firstValueFrom(this.dataStore.getTodos()); + this.todos.set(todos); } - update(todo: any) { - this.http - .put( - `https://jsonplaceholder.typicode.com/todos/${todo.id}`, - JSON.stringify({ - todo: todo.id, - title: randText(), - body: todo.body, - userId: todo.userId, - }), - { - headers: { - 'Content-type': 'application/json; charset=UTF-8', - }, - }, - ) - .subscribe((todoUpdated: any) => { - this.todos[todoUpdated.id - 1] = todoUpdated; - }); + update(todo: todo) { + this.dataStore.updateTodo(todo).subscribe((todoUpdated: todo) => { + this.todos.update((todos) => + todos.map((t) => (t.id === todoUpdated.id ? todoUpdated : t)), + ); + }); } } diff --git a/apps/angular/5-crud-application/src/app/app.model.ts b/apps/angular/5-crud-application/src/app/app.model.ts new file mode 100644 index 000000000..b082ba721 --- /dev/null +++ b/apps/angular/5-crud-application/src/app/app.model.ts @@ -0,0 +1,14 @@ +export interface todo { + todo: number; + title: string; + userId: number; + id: number; + body: string; +} + +export interface todoUpdated { + todo: number; + title: string; + userId: number; + id: number; +} diff --git a/apps/angular/5-crud-application/src/app/app.service.ts b/apps/angular/5-crud-application/src/app/app.service.ts new file mode 100644 index 000000000..c86b480de --- /dev/null +++ b/apps/angular/5-crud-application/src/app/app.service.ts @@ -0,0 +1,32 @@ +import { HttpClient } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; +import { randText } from '@ngneat/falso'; +import { todo } from './app.model'; + +@Injectable({ + providedIn: 'root', // Makes the service a singleton available throughout the app +}) +export class ServiceApp { + private http = inject(HttpClient); + + getTodos() { + return this.http.get('https://jsonplaceholder.typicode.com/todos'); + } + + updateTodo(todo: todo) { + return this.http.put( + `https://jsonplaceholder.typicode.com/todos/${todo.id}`, + JSON.stringify({ + todo: todo.id, + title: randText(), + body: todo.body, + userId: todo.userId, + }), + { + headers: { + 'Content-type': 'application/json; charset=UTF-8', + }, + }, + ); + } +} From 8d9fad2f0548dcae375862c355b55f8132dcca69 Mon Sep 17 00:00:00 2001 From: Korno Date: Tue, 27 Jan 2026 15:55:30 +0200 Subject: [PATCH 2/7] refactor: move logic to the todo component --- .../app/{app.component.ts => todo.component.ts} | 15 +++++++++++---- .../src/app/{app.model.ts => todo.model.ts} | 0 .../src/app/{app.service.ts => todo.service.ts} | 8 +++++++- apps/angular/5-crud-application/src/index.html | 2 +- apps/angular/5-crud-application/src/main.ts | 4 ++-- 5 files changed, 21 insertions(+), 8 deletions(-) rename apps/angular/5-crud-application/src/app/{app.component.ts => todo.component.ts} (65%) rename apps/angular/5-crud-application/src/app/{app.model.ts => todo.model.ts} (100%) rename apps/angular/5-crud-application/src/app/{app.service.ts => todo.service.ts} (82%) diff --git a/apps/angular/5-crud-application/src/app/app.component.ts b/apps/angular/5-crud-application/src/app/todo.component.ts similarity index 65% rename from apps/angular/5-crud-application/src/app/app.component.ts rename to apps/angular/5-crud-application/src/app/todo.component.ts index f3eb75db9..929a9d167 100644 --- a/apps/angular/5-crud-application/src/app/app.component.ts +++ b/apps/angular/5-crud-application/src/app/todo.component.ts @@ -1,20 +1,21 @@ import { Component, inject, OnInit, signal } from '@angular/core'; import { firstValueFrom } from 'rxjs'; -import { todo } from './app.model'; -import { ServiceApp } from './app.service'; +import { todo } from './todo.model'; +import { ServiceApp } from './todo.service'; @Component({ imports: [], - selector: 'app-root', + selector: 'app-todo', template: ` @for (todo of todos(); track todo.id) { {{ todo.title }} + } `, styles: [], }) -export class AppComponent implements OnInit { +export class TodoComponent implements OnInit { dataStore = inject(ServiceApp); todos = signal([]); @@ -30,4 +31,10 @@ export class AppComponent implements OnInit { ); }); } + + delete(todo: todo) { + this.dataStore.deleteTodo(todo).subscribe(() => { + this.todos.update((todos) => todos.filter((t) => t.id !== todo.id)); + }); + } } diff --git a/apps/angular/5-crud-application/src/app/app.model.ts b/apps/angular/5-crud-application/src/app/todo.model.ts similarity index 100% rename from apps/angular/5-crud-application/src/app/app.model.ts rename to apps/angular/5-crud-application/src/app/todo.model.ts diff --git a/apps/angular/5-crud-application/src/app/app.service.ts b/apps/angular/5-crud-application/src/app/todo.service.ts similarity index 82% rename from apps/angular/5-crud-application/src/app/app.service.ts rename to apps/angular/5-crud-application/src/app/todo.service.ts index c86b480de..3c7da686c 100644 --- a/apps/angular/5-crud-application/src/app/app.service.ts +++ b/apps/angular/5-crud-application/src/app/todo.service.ts @@ -1,7 +1,7 @@ import { HttpClient } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; import { randText } from '@ngneat/falso'; -import { todo } from './app.model'; +import { todo } from './todo.model'; @Injectable({ providedIn: 'root', // Makes the service a singleton available throughout the app @@ -29,4 +29,10 @@ export class ServiceApp { }, ); } + + deleteTodo(todo: todo) { + return this.http.delete( + `https://jsonplaceholder.typicode.com/todos/${todo.id}`, + ); + } } diff --git a/apps/angular/5-crud-application/src/index.html b/apps/angular/5-crud-application/src/index.html index b9ec0b609..eee8b429c 100644 --- a/apps/angular/5-crud-application/src/index.html +++ b/apps/angular/5-crud-application/src/index.html @@ -15,6 +15,6 @@ rel="stylesheet" /> - + diff --git a/apps/angular/5-crud-application/src/main.ts b/apps/angular/5-crud-application/src/main.ts index 866d45959..47442ee6b 100644 --- a/apps/angular/5-crud-application/src/main.ts +++ b/apps/angular/5-crud-application/src/main.ts @@ -2,9 +2,9 @@ import { provideZoneChangeDetection } from '@angular/core'; import { appConfig } from './app/app.config'; import { bootstrapApplication } from '@angular/platform-browser'; -import { AppComponent } from './app/app.component'; +import { TodoComponent } from './app/todo.component'; -bootstrapApplication(AppComponent, { +bootstrapApplication(TodoComponent, { ...appConfig, providers: [provideZoneChangeDetection(), ...appConfig.providers], }).catch((err) => console.error(err)); From 4ddc28d0770a0e1e2287e75e5219ea421cf142f6 Mon Sep 17 00:00:00 2001 From: Korno Date: Tue, 27 Jan 2026 16:37:47 +0200 Subject: [PATCH 3/7] feat: add global http error handler - interceptor --- .../5-crud-application/src/app/app.config.ts | 5 +++-- .../src/app/http-error.interceptor.ts | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 apps/angular/5-crud-application/src/app/http-error.interceptor.ts diff --git a/apps/angular/5-crud-application/src/app/app.config.ts b/apps/angular/5-crud-application/src/app/app.config.ts index 1c0c9422f..8500fa410 100644 --- a/apps/angular/5-crud-application/src/app/app.config.ts +++ b/apps/angular/5-crud-application/src/app/app.config.ts @@ -1,6 +1,7 @@ -import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { ApplicationConfig } from '@angular/core'; +import { httpErrorInterceptor } from './http-error.interceptor'; export const appConfig: ApplicationConfig = { - providers: [provideHttpClient()], + providers: [provideHttpClient(withInterceptors([httpErrorInterceptor]))], }; diff --git a/apps/angular/5-crud-application/src/app/http-error.interceptor.ts b/apps/angular/5-crud-application/src/app/http-error.interceptor.ts new file mode 100644 index 000000000..29c49f153 --- /dev/null +++ b/apps/angular/5-crud-application/src/app/http-error.interceptor.ts @@ -0,0 +1,20 @@ +import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http'; +import { throwError } from 'rxjs/internal/observable/throwError'; +import { catchError } from 'rxjs/operators'; + +export const httpErrorInterceptor: HttpInterceptorFn = (req, next) => { + return next(req).pipe( + catchError((error: HttpErrorResponse) => { + switch (error.status) { + case 404: + return throwError(() => new Error('Resource is not found')); + case 401: + return throwError(() => new Error('Unauthorized')); + case 403: + return throwError(() => new Error('Forbidden')); + default: + return throwError(() => new Error('Something wrong happened')); + } + }), + ); +}; From 8ad66c65def0b878c2aaf5f92e37567465f73b94 Mon Sep 17 00:00:00 2001 From: Korno Date: Tue, 27 Jan 2026 18:43:17 +0200 Subject: [PATCH 4/7] feat: add mat-spinner --- .../src/app/todo.component.ts | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/apps/angular/5-crud-application/src/app/todo.component.ts b/apps/angular/5-crud-application/src/app/todo.component.ts index 929a9d167..9c73b95a5 100644 --- a/apps/angular/5-crud-application/src/app/todo.component.ts +++ b/apps/angular/5-crud-application/src/app/todo.component.ts @@ -1,16 +1,21 @@ import { Component, inject, OnInit, signal } from '@angular/core'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { firstValueFrom } from 'rxjs'; import { todo } from './todo.model'; import { ServiceApp } from './todo.service'; @Component({ - imports: [], + imports: [MatProgressSpinnerModule], selector: 'app-todo', template: ` - @for (todo of todos(); track todo.id) { - {{ todo.title }} - - + @if (isLoading()) { + + } @else { + @for (todo of todos(); track todo.id) { + {{ todo.title }} + + + } } `, styles: [], @@ -18,23 +23,29 @@ import { ServiceApp } from './todo.service'; export class TodoComponent implements OnInit { dataStore = inject(ServiceApp); todos = signal([]); + isLoading = signal(true); async ngOnInit() { const todos = await firstValueFrom(this.dataStore.getTodos()); this.todos.set(todos); + this.isLoading.set(false); } update(todo: todo) { + this.isLoading.set(true); this.dataStore.updateTodo(todo).subscribe((todoUpdated: todo) => { this.todos.update((todos) => todos.map((t) => (t.id === todoUpdated.id ? todoUpdated : t)), ); + this.isLoading.set(false); }); } delete(todo: todo) { + this.isLoading.set(true); this.dataStore.deleteTodo(todo).subscribe(() => { this.todos.update((todos) => todos.filter((t) => t.id !== todo.id)); + this.isLoading.set(false); }); } } From 964e3c4316f7d5bda3a28c13e42c66000ff0a8ba Mon Sep 17 00:00:00 2001 From: Korno Date: Tue, 3 Feb 2026 18:49:30 +0200 Subject: [PATCH 5/7] feat: implement component store --- .../src/app/todo.component.ts | 51 ----------- .../src/app/todo/todo.component.spec.ts | 86 +++++++++++++++++++ .../src/app/todo/todo.component.ts | 40 +++++++++ .../src/app/{ => todo}/todo.model.ts | 0 .../src/app/{ => todo}/todo.service.ts | 3 +- .../src/app/todo/todo.state.ts | 6 ++ .../src/app/todo/todo.store.ts | 81 +++++++++++++++++ apps/angular/5-crud-application/src/main.ts | 2 +- 8 files changed, 216 insertions(+), 53 deletions(-) delete mode 100644 apps/angular/5-crud-application/src/app/todo.component.ts create mode 100644 apps/angular/5-crud-application/src/app/todo/todo.component.spec.ts create mode 100644 apps/angular/5-crud-application/src/app/todo/todo.component.ts rename apps/angular/5-crud-application/src/app/{ => todo}/todo.model.ts (100%) rename apps/angular/5-crud-application/src/app/{ => todo}/todo.service.ts (87%) create mode 100644 apps/angular/5-crud-application/src/app/todo/todo.state.ts create mode 100644 apps/angular/5-crud-application/src/app/todo/todo.store.ts diff --git a/apps/angular/5-crud-application/src/app/todo.component.ts b/apps/angular/5-crud-application/src/app/todo.component.ts deleted file mode 100644 index 9c73b95a5..000000000 --- a/apps/angular/5-crud-application/src/app/todo.component.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Component, inject, OnInit, signal } from '@angular/core'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { firstValueFrom } from 'rxjs'; -import { todo } from './todo.model'; -import { ServiceApp } from './todo.service'; - -@Component({ - imports: [MatProgressSpinnerModule], - selector: 'app-todo', - template: ` - @if (isLoading()) { - - } @else { - @for (todo of todos(); track todo.id) { - {{ todo.title }} - - - } - } - `, - styles: [], -}) -export class TodoComponent implements OnInit { - dataStore = inject(ServiceApp); - todos = signal([]); - isLoading = signal(true); - - async ngOnInit() { - const todos = await firstValueFrom(this.dataStore.getTodos()); - this.todos.set(todos); - this.isLoading.set(false); - } - - update(todo: todo) { - this.isLoading.set(true); - this.dataStore.updateTodo(todo).subscribe((todoUpdated: todo) => { - this.todos.update((todos) => - todos.map((t) => (t.id === todoUpdated.id ? todoUpdated : t)), - ); - this.isLoading.set(false); - }); - } - - delete(todo: todo) { - this.isLoading.set(true); - this.dataStore.deleteTodo(todo).subscribe(() => { - this.todos.update((todos) => todos.filter((t) => t.id !== todo.id)); - this.isLoading.set(false); - }); - } -} diff --git a/apps/angular/5-crud-application/src/app/todo/todo.component.spec.ts b/apps/angular/5-crud-application/src/app/todo/todo.component.spec.ts new file mode 100644 index 000000000..890632d47 --- /dev/null +++ b/apps/angular/5-crud-application/src/app/todo/todo.component.spec.ts @@ -0,0 +1,86 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { firstValueFrom, map, of, Subject } from 'rxjs'; +import { todo } from '../todo/todo.model'; +import { TodoComponent } from './todo.component'; +import { ServiceTodo } from './todo.service'; + +describe('TodoComponent', () => { + let fixture: ComponentFixture; + let component: TodoComponent; + let todosSubject: Subject; + + // Mock data for todos + const todosMock: todo[] = [ + { todo: 1, id: 1, title: 'First', userId: 1, body: 'First todo' }, + { todo: 2, id: 2, title: 'Second', userId: 1, body: 'Second todo' }, + ]; + // Mock implementation of ServiceTodo + const serviceMock = { + getTodos: jest.fn(() => todosSubject.asObservable()), + updateTodo: jest + .fn() + .mockImplementation((t: todo) => + of({ ...t, title: t.title + ' Updated' }), + ), + deleteTodo: jest.fn().mockReturnValue(of(null)), + }; + + beforeEach(async () => { + todosSubject = new Subject(); + await TestBed.configureTestingModule({ + imports: [TodoComponent, MatProgressSpinnerModule], + providers: [{ provide: ServiceTodo, useValue: serviceMock }], + }).compileComponents(); + + fixture = TestBed.createComponent(TodoComponent); + component = fixture.componentInstance; + }); + // ---- Test 1: spinner is shown while loading ---- + it('should show spinner while loading', () => { + fixture.detectChanges(); // triggers async pipe + + const spinner = fixture.nativeElement.querySelector('mat-spinner'); + expect(spinner).toBeTruthy(); + }); + // ---- Test 2: todos are loaded after init ---- + it('should load todos on init', async () => { + fixture.detectChanges(); + + todosSubject.next(todosMock); // now resolve the data + await fixture.whenStable(); + fixture.detectChanges(); + + const todosLength = await firstValueFrom( + component.todos$.pipe(map((todos) => todos.length)), + ); + expect(todosLength).toBe(2); + + const text = fixture.nativeElement.textContent; + expect(text).toContain('First'); + expect(text).toContain('Second'); + }); + // ---- Test 3: todo is removed ---- + it('should remove todo after delete', async () => { + fixture.detectChanges(); + + todosSubject.next(todosMock); // resolve the data first + await fixture.whenStable(); + fixture.detectChanges(); + + const firstTodo = (await firstValueFrom(component.todos$))[0]; + component.delete(firstTodo); + fixture.detectChanges(); + + const todosLength = await firstValueFrom( + component.todos$.pipe(map((todos) => todos.length)), + ); + expect(todosLength).toBe(1); + expect( + (await firstValueFrom(component.todos$)).find( + (t) => t.id === firstTodo.id, + ), + ).toBeUndefined(); + expect(serviceMock.deleteTodo).toHaveBeenCalledWith(firstTodo); + }); +}); diff --git a/apps/angular/5-crud-application/src/app/todo/todo.component.ts b/apps/angular/5-crud-application/src/app/todo/todo.component.ts new file mode 100644 index 000000000..1c69332ee --- /dev/null +++ b/apps/angular/5-crud-application/src/app/todo/todo.component.ts @@ -0,0 +1,40 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnInit } from '@angular/core'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { todo } from './todo.model'; +import { TodoStore } from './todo.store'; + +@Component({ + imports: [MatProgressSpinnerModule, CommonModule], + selector: 'app-todo', + template: ` + @if (isLoading$ | async) { + + } @else { + @for (todo of todos$ | async; track todo.id) { + {{ todo.title }} + + + } + } + `, + styles: [], +}) +export class TodoComponent implements OnInit { + readonly todos$ = this.store.todos$; + readonly isLoading$ = this.store.isLoading$; + + constructor(private store: TodoStore) {} + + ngOnInit() { + this.store.loadTodos(); + } + + update(todo: todo) { + this.store.updateTodo(todo); + } + + delete(todo: todo) { + this.store.deleteTodo(todo); + } +} diff --git a/apps/angular/5-crud-application/src/app/todo.model.ts b/apps/angular/5-crud-application/src/app/todo/todo.model.ts similarity index 100% rename from apps/angular/5-crud-application/src/app/todo.model.ts rename to apps/angular/5-crud-application/src/app/todo/todo.model.ts diff --git a/apps/angular/5-crud-application/src/app/todo.service.ts b/apps/angular/5-crud-application/src/app/todo/todo.service.ts similarity index 87% rename from apps/angular/5-crud-application/src/app/todo.service.ts rename to apps/angular/5-crud-application/src/app/todo/todo.service.ts index 3c7da686c..cb47b4ba2 100644 --- a/apps/angular/5-crud-application/src/app/todo.service.ts +++ b/apps/angular/5-crud-application/src/app/todo/todo.service.ts @@ -6,7 +6,7 @@ import { todo } from './todo.model'; @Injectable({ providedIn: 'root', // Makes the service a singleton available throughout the app }) -export class ServiceApp { +export class ServiceTodo { private http = inject(HttpClient); getTodos() { @@ -31,6 +31,7 @@ export class ServiceApp { } deleteTodo(todo: todo) { + this.http.delete('https://jsonplaceholder.typicode.com/WRONG_URL'); // To demonstrate error handling return this.http.delete( `https://jsonplaceholder.typicode.com/todos/${todo.id}`, ); diff --git a/apps/angular/5-crud-application/src/app/todo/todo.state.ts b/apps/angular/5-crud-application/src/app/todo/todo.state.ts new file mode 100644 index 000000000..d7dcdc973 --- /dev/null +++ b/apps/angular/5-crud-application/src/app/todo/todo.state.ts @@ -0,0 +1,6 @@ +import { todo } from './todo.model'; + +export interface TodoState { + todos: todo[]; + isLoading: boolean; +} diff --git a/apps/angular/5-crud-application/src/app/todo/todo.store.ts b/apps/angular/5-crud-application/src/app/todo/todo.store.ts new file mode 100644 index 000000000..02789bbc8 --- /dev/null +++ b/apps/angular/5-crud-application/src/app/todo/todo.store.ts @@ -0,0 +1,81 @@ +import { inject, Injectable } from '@angular/core'; +import { ComponentStore } from '@ngrx/component-store'; +import { switchMap } from 'rxjs/internal/operators/switchMap'; +import { tap } from 'rxjs/internal/operators/tap'; +import { todo } from './todo.model'; +import { ServiceTodo } from './todo.service'; +import { TodoState } from './todo.state'; + +@Injectable({ + providedIn: 'root', // This makes the service a singleton available everywhere +}) +export class TodoStore extends ComponentStore { + constructor() { + super({ + todos: [], + isLoading: true, + }); + } + + private service = inject(ServiceTodo); + + // selectors + readonly todos$ = this.select((state) => state.todos); + readonly isLoading$ = this.select((state) => state.isLoading); + + // updaters + readonly setLoading = this.updater((state, isLoading) => ({ + ...state, + isLoading, + })); + + readonly setTodos = this.updater((state, todos) => ({ + ...state, + todos, + })); + + // effects + readonly loadTodos = this.effect((trigger$) => + trigger$.pipe( + tap(() => this.setLoading(true)), + switchMap(() => + this.service.getTodos().pipe( + tap((todos) => { + this.setTodos(todos); + this.setLoading(false); + }), + ), + ), + ), + ); + + readonly updateTodo = this.effect((todo$) => + todo$.pipe( + tap(() => this.setLoading(true)), + switchMap((todo) => + this.service.updateTodo(todo).pipe( + tap((updated) => { + this.setTodos( + this.get().todos.map((t) => (t.id === updated.id ? updated : t)), + ); + this.setLoading(false); + }), + ), + ), + ), + ); + + readonly deleteTodo = this.effect((todo$) => + todo$.pipe( + tap(() => this.setLoading(true)), + switchMap((todo) => + this.service.deleteTodo(todo).pipe( + tap(() => { + this.setTodos(this.get().todos.filter((t) => t.id !== todo.id)); + this.setLoading(false); + }), + ), + ), + ), + ); +} diff --git a/apps/angular/5-crud-application/src/main.ts b/apps/angular/5-crud-application/src/main.ts index 47442ee6b..9dfadcf4c 100644 --- a/apps/angular/5-crud-application/src/main.ts +++ b/apps/angular/5-crud-application/src/main.ts @@ -2,7 +2,7 @@ import { provideZoneChangeDetection } from '@angular/core'; import { appConfig } from './app/app.config'; import { bootstrapApplication } from '@angular/platform-browser'; -import { TodoComponent } from './app/todo.component'; +import { TodoComponent } from './app/todo/todo.component'; bootstrapApplication(TodoComponent, { ...appConfig, From b4a624aaf91a21e8f5d6dfcc4a2ca0526433ae18 Mon Sep 17 00:00:00 2001 From: Korno Date: Sat, 7 Feb 2026 12:09:32 +0200 Subject: [PATCH 6/7] feat: add localized Loading/Error indicator --- .../app/todo/todo-item/todo-item.component.ts | 36 +++++++++++++ .../src/app/todo/todo.component.ts | 22 ++++---- .../src/app/todo/todo.service.ts | 1 - .../src/app/todo/todo.state.ts | 2 + .../src/app/todo/todo.store.ts | 53 +++++++++++++++++-- .../5-crud-application/src/styles.scss | 6 +++ 6 files changed, 104 insertions(+), 16 deletions(-) create mode 100644 apps/angular/5-crud-application/src/app/todo/todo-item/todo-item.component.ts diff --git a/apps/angular/5-crud-application/src/app/todo/todo-item/todo-item.component.ts b/apps/angular/5-crud-application/src/app/todo/todo-item/todo-item.component.ts new file mode 100644 index 000000000..aa298366e --- /dev/null +++ b/apps/angular/5-crud-application/src/app/todo/todo-item/todo-item.component.ts @@ -0,0 +1,36 @@ +import { AsyncPipe } from '@angular/common'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { todo } from '../todo.model'; + +@Component({ + selector: 'app-todo-item', + imports: [MatProgressSpinnerModule, AsyncPipe], + template: ` +
+ {{ todo.title }} +
+ @if (isProcessing) { + + } + @if (error) { +
{{ error }}
+ } + + +
+ `, + styles: ['.processing {opacity: 0.6; pointer-events: none;}'], +}) +export class TodoItemComponent { + @Input({ required: true }) todo!: todo; + @Input() isProcessing: boolean = false; + @Input() error: string | null = null; + + @Output() update = new EventEmitter(); + @Output() delete = new EventEmitter(); + + onUpdate() { + this.update.emit(this.todo); + } +} diff --git a/apps/angular/5-crud-application/src/app/todo/todo.component.ts b/apps/angular/5-crud-application/src/app/todo/todo.component.ts index 1c69332ee..7aecddc23 100644 --- a/apps/angular/5-crud-application/src/app/todo/todo.component.ts +++ b/apps/angular/5-crud-application/src/app/todo/todo.component.ts @@ -1,30 +1,30 @@ import { CommonModule } from '@angular/common'; import { Component, OnInit } from '@angular/core'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { TodoItemComponent } from './todo-item/todo-item.component'; import { todo } from './todo.model'; import { TodoStore } from './todo.store'; @Component({ - imports: [MatProgressSpinnerModule, CommonModule], + imports: [MatProgressSpinnerModule, CommonModule, TodoItemComponent], selector: 'app-todo', template: ` - @if (isLoading$ | async) { + @if (store.isLoading$ | async) { } @else { - @for (todo of todos$ | async; track todo.id) { - {{ todo.title }} - - + @for (todo of store.todos$ | async; track todo.id) { + } } `, - styles: [], }) export class TodoComponent implements OnInit { - readonly todos$ = this.store.todos$; - readonly isLoading$ = this.store.isLoading$; - - constructor(private store: TodoStore) {} + constructor(readonly store: TodoStore) {} ngOnInit() { this.store.loadTodos(); diff --git a/apps/angular/5-crud-application/src/app/todo/todo.service.ts b/apps/angular/5-crud-application/src/app/todo/todo.service.ts index cb47b4ba2..33908a40e 100644 --- a/apps/angular/5-crud-application/src/app/todo/todo.service.ts +++ b/apps/angular/5-crud-application/src/app/todo/todo.service.ts @@ -31,7 +31,6 @@ export class ServiceTodo { } deleteTodo(todo: todo) { - this.http.delete('https://jsonplaceholder.typicode.com/WRONG_URL'); // To demonstrate error handling return this.http.delete( `https://jsonplaceholder.typicode.com/todos/${todo.id}`, ); diff --git a/apps/angular/5-crud-application/src/app/todo/todo.state.ts b/apps/angular/5-crud-application/src/app/todo/todo.state.ts index d7dcdc973..6360693b6 100644 --- a/apps/angular/5-crud-application/src/app/todo/todo.state.ts +++ b/apps/angular/5-crud-application/src/app/todo/todo.state.ts @@ -3,4 +3,6 @@ import { todo } from './todo.model'; export interface TodoState { todos: todo[]; isLoading: boolean; + processingIds: Set; + errors: Record; } diff --git a/apps/angular/5-crud-application/src/app/todo/todo.store.ts b/apps/angular/5-crud-application/src/app/todo/todo.store.ts index 02789bbc8..bcc4bb6d5 100644 --- a/apps/angular/5-crud-application/src/app/todo/todo.store.ts +++ b/apps/angular/5-crud-application/src/app/todo/todo.store.ts @@ -1,5 +1,6 @@ import { inject, Injectable } from '@angular/core'; import { ComponentStore } from '@ngrx/component-store'; +import { catchError, EMPTY } from 'rxjs'; import { switchMap } from 'rxjs/internal/operators/switchMap'; import { tap } from 'rxjs/internal/operators/tap'; import { todo } from './todo.model'; @@ -14,6 +15,8 @@ export class TodoStore extends ComponentStore { super({ todos: [], isLoading: true, + processingIds: new Set(), + errors: {}, }); } @@ -22,6 +25,14 @@ export class TodoStore extends ComponentStore { // selectors readonly todos$ = this.select((state) => state.todos); readonly isLoading$ = this.select((state) => state.isLoading); + readonly processingIds$ = this.select((state) => state.processingIds); + readonly isTodoProcessing$ = (id: number) => + this.select(this.processingIds$, (ids) => ids.has(id)); + readonly todoError$ = (id: number) => + this.select( + this.select((state) => state.errors), + (errors) => errors[id], + ); // updaters readonly setLoading = this.updater((state, isLoading) => ({ @@ -34,6 +45,24 @@ export class TodoStore extends ComponentStore { todos, })); + readonly addProcessingId = this.updater((state, id) => ({ + ...state, + processingIds: new Set(state.processingIds).add(id), + })); + + readonly removeProcessingId = this.updater((state, id) => { + const ids = new Set(state.processingIds); + ids.delete(id); + return { ...state, processingIds: ids }; + }); + + readonly setTodoError = this.updater<{ id: number; error: string | null }>( + (state, { id, error }) => ({ + ...state, + errors: { ...state.errors, [id]: error }, + }), + ); + // effects readonly loadTodos = this.effect((trigger$) => trigger$.pipe( @@ -51,14 +80,22 @@ export class TodoStore extends ComponentStore { readonly updateTodo = this.effect((todo$) => todo$.pipe( - tap(() => this.setLoading(true)), + tap((todo) => { + this.addProcessingId(todo.id); + this.setTodoError({ id: todo.id, error: null }); + }), switchMap((todo) => this.service.updateTodo(todo).pipe( tap((updated) => { this.setTodos( this.get().todos.map((t) => (t.id === updated.id ? updated : t)), ); - this.setLoading(false); + this.removeProcessingId(todo.id); + }), + catchError((err) => { + this.setTodoError({ id: todo.id, error: 'Update failed' }); + this.removeProcessingId(todo.id); + return EMPTY; }), ), ), @@ -67,12 +104,20 @@ export class TodoStore extends ComponentStore { readonly deleteTodo = this.effect((todo$) => todo$.pipe( - tap(() => this.setLoading(true)), + tap((todo) => { + this.addProcessingId(todo.id); + this.setTodoError({ id: todo.id, error: null }); + }), switchMap((todo) => this.service.deleteTodo(todo).pipe( tap(() => { this.setTodos(this.get().todos.filter((t) => t.id !== todo.id)); - this.setLoading(false); + this.removeProcessingId(todo.id); + }), + catchError((err) => { + this.setTodoError({ id: todo.id, error: 'Delete failed' }); + this.removeProcessingId(todo.id); + return EMPTY; }), ), ), diff --git a/apps/angular/5-crud-application/src/styles.scss b/apps/angular/5-crud-application/src/styles.scss index 5fb5bb00b..d5172da86 100644 --- a/apps/angular/5-crud-application/src/styles.scss +++ b/apps/angular/5-crud-application/src/styles.scss @@ -8,3 +8,9 @@ body { margin: 0; font-family: Roboto, 'Helvetica Neue', sans-serif; } + +app-todo { + display: flex; + flex-direction: column; + gap: 1rem; +} From ce4a90aacb6a9ea248ecfb395779626f2f0a73d2 Mon Sep 17 00:00:00 2001 From: Korno Date: Sat, 7 Feb 2026 12:39:24 +0200 Subject: [PATCH 7/7] refactor: update tests by store --- .../src/app/todo/todo.component.spec.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/angular/5-crud-application/src/app/todo/todo.component.spec.ts b/apps/angular/5-crud-application/src/app/todo/todo.component.spec.ts index 890632d47..9566786be 100644 --- a/apps/angular/5-crud-application/src/app/todo/todo.component.spec.ts +++ b/apps/angular/5-crud-application/src/app/todo/todo.component.spec.ts @@ -4,11 +4,13 @@ import { firstValueFrom, map, of, Subject } from 'rxjs'; import { todo } from '../todo/todo.model'; import { TodoComponent } from './todo.component'; import { ServiceTodo } from './todo.service'; +import { TodoStore } from './todo.store'; describe('TodoComponent', () => { let fixture: ComponentFixture; let component: TodoComponent; let todosSubject: Subject; + let store: TodoStore; // Mock data for todos const todosMock: todo[] = [ @@ -35,6 +37,8 @@ describe('TodoComponent', () => { fixture = TestBed.createComponent(TodoComponent); component = fixture.componentInstance; + // inject the store after TestBed + store = TestBed.inject(TodoStore); }); // ---- Test 1: spinner is shown while loading ---- it('should show spinner while loading', () => { @@ -52,7 +56,7 @@ describe('TodoComponent', () => { fixture.detectChanges(); const todosLength = await firstValueFrom( - component.todos$.pipe(map((todos) => todos.length)), + store.todos$.pipe(map((todos) => todos.length)), ); expect(todosLength).toBe(2); @@ -68,18 +72,16 @@ describe('TodoComponent', () => { await fixture.whenStable(); fixture.detectChanges(); - const firstTodo = (await firstValueFrom(component.todos$))[0]; + const firstTodo = (await firstValueFrom(store.todos$))[0]; component.delete(firstTodo); fixture.detectChanges(); const todosLength = await firstValueFrom( - component.todos$.pipe(map((todos) => todos.length)), + store.todos$.pipe(map((todos) => todos.length)), ); expect(todosLength).toBe(1); expect( - (await firstValueFrom(component.todos$)).find( - (t) => t.id === firstTodo.id, - ), + (await firstValueFrom(store.todos$)).find((t) => t.id === firstTodo.id), ).toBeUndefined(); expect(serviceMock.deleteTodo).toHaveBeenCalledWith(firstTodo); });