diff --git a/apps/angular/5-crud-application/src/app/app.component.ts b/apps/angular/5-crud-application/src/app/app.component.ts
deleted file mode 100644
index 73ba0dc34..000000000
--- a/apps/angular/5-crud-application/src/app/app.component.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import { HttpClient } from '@angular/common/http';
-import { Component, inject, OnInit } from '@angular/core';
-import { randText } from '@ngneat/falso';
-
-@Component({
- imports: [],
- selector: 'app-root',
- template: `
- @for (todo of todos; track todo.id) {
- {{ todo.title }}
-
- }
- `,
- styles: [],
-})
-export class AppComponent implements OnInit {
- private http = inject(HttpClient);
-
- todos!: any[];
-
- ngOnInit(): void {
- this.http
- .get('https://jsonplaceholder.typicode.com/todos')
- .subscribe((todos) => {
- this.todos = 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;
- });
- }
-}
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'));
+ }
+ }),
+ );
+};
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.spec.ts b/apps/angular/5-crud-application/src/app/todo/todo.component.spec.ts
new file mode 100644
index 000000000..9566786be
--- /dev/null
+++ b/apps/angular/5-crud-application/src/app/todo/todo.component.spec.ts
@@ -0,0 +1,88 @@
+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';
+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[] = [
+ { 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;
+ // inject the store after TestBed
+ store = TestBed.inject(TodoStore);
+ });
+ // ---- 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(
+ store.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(store.todos$))[0];
+ component.delete(firstTodo);
+ fixture.detectChanges();
+
+ const todosLength = await firstValueFrom(
+ store.todos$.pipe(map((todos) => todos.length)),
+ );
+ expect(todosLength).toBe(1);
+ expect(
+ (await firstValueFrom(store.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..7aecddc23
--- /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 { TodoItemComponent } from './todo-item/todo-item.component';
+import { todo } from './todo.model';
+import { TodoStore } from './todo.store';
+
+@Component({
+ imports: [MatProgressSpinnerModule, CommonModule, TodoItemComponent],
+ selector: 'app-todo',
+ template: `
+ @if (store.isLoading$ | async) {
+
+ } @else {
+ @for (todo of store.todos$ | async; track todo.id) {
+
+ }
+ }
+ `,
+})
+export class TodoComponent implements OnInit {
+ constructor(readonly 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/todo.model.ts b/apps/angular/5-crud-application/src/app/todo/todo.model.ts
new file mode 100644
index 000000000..b082ba721
--- /dev/null
+++ b/apps/angular/5-crud-application/src/app/todo/todo.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/todo/todo.service.ts b/apps/angular/5-crud-application/src/app/todo/todo.service.ts
new file mode 100644
index 000000000..33908a40e
--- /dev/null
+++ b/apps/angular/5-crud-application/src/app/todo/todo.service.ts
@@ -0,0 +1,38 @@
+import { HttpClient } from '@angular/common/http';
+import { inject, Injectable } from '@angular/core';
+import { randText } from '@ngneat/falso';
+import { todo } from './todo.model';
+
+@Injectable({
+ providedIn: 'root', // Makes the service a singleton available throughout the app
+})
+export class ServiceTodo {
+ 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',
+ },
+ },
+ );
+ }
+
+ deleteTodo(todo: todo) {
+ 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..6360693b6
--- /dev/null
+++ b/apps/angular/5-crud-application/src/app/todo/todo.state.ts
@@ -0,0 +1,8 @@
+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
new file mode 100644
index 000000000..bcc4bb6d5
--- /dev/null
+++ b/apps/angular/5-crud-application/src/app/todo/todo.store.ts
@@ -0,0 +1,126 @@
+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';
+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,
+ processingIds: new Set(),
+ errors: {},
+ });
+ }
+
+ private service = inject(ServiceTodo);
+
+ // 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) => ({
+ ...state,
+ isLoading,
+ }));
+
+ readonly setTodos = this.updater((state, todos) => ({
+ ...state,
+ 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(
+ 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((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.removeProcessingId(todo.id);
+ }),
+ catchError((err) => {
+ this.setTodoError({ id: todo.id, error: 'Update failed' });
+ this.removeProcessingId(todo.id);
+ return EMPTY;
+ }),
+ ),
+ ),
+ ),
+ );
+
+ readonly deleteTodo = this.effect((todo$) =>
+ todo$.pipe(
+ 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.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/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" />
-
+