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 8f8000bbb..3fb72f62e 100644
--- a/apps/angular/5-crud-application/src/app/app.component.ts
+++ b/apps/angular/5-crud-application/src/app/app.component.ts
@@ -1,55 +1,46 @@
-import { HttpClient } from '@angular/common/http';
import {
ChangeDetectionStrategy,
Component,
inject,
OnInit,
} from '@angular/core';
-import { randText } from '@ngneat/falso';
+import { TodoStore } from './todo.store';
@Component({
imports: [],
selector: 'app-root',
template: `
- @for (todo of todos; track todo.id) {
+ @if (todoStore.isLoading()) {
+
Loading...
+ }
+ @for (todo of todoStore.todos(); track todo.id) {
{{ todo.title }}
-
+
+
+
}
`,
changeDetection: ChangeDetectionStrategy.Eager,
- styles: [],
+ styles: `
+ .loading-state {
+ display: flex;
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(255, 255, 255, 0.4);
+ justify-content: center;
+ align-items: center;
+ font-weight: 700;
+ font-size: 46px;
+ }
+ `,
})
export class AppComponent implements OnInit {
- private http = inject(HttpClient);
-
- todos!: any[];
+ public todoStore: TodoStore = inject(TodoStore);
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;
- });
+ this.todoStore.getAll();
}
}
diff --git a/apps/angular/5-crud-application/src/app/app.interface.ts b/apps/angular/5-crud-application/src/app/app.interface.ts
new file mode 100644
index 000000000..b0b1d833a
--- /dev/null
+++ b/apps/angular/5-crud-application/src/app/app.interface.ts
@@ -0,0 +1,6 @@
+export interface ITodo {
+ title: string;
+ id: number;
+ userId: number;
+ completed: boolean;
+}
diff --git a/apps/angular/5-crud-application/src/app/http.service.ts b/apps/angular/5-crud-application/src/app/http.service.ts
new file mode 100644
index 000000000..ba1e591ee
--- /dev/null
+++ b/apps/angular/5-crud-application/src/app/http.service.ts
@@ -0,0 +1,35 @@
+import { HttpClient } from '@angular/common/http';
+import { inject, Injectable } from '@angular/core';
+import { randText } from '@ngneat/falso';
+import { Observable } from 'rxjs';
+import { ITodo } from './app.interface';
+
+@Injectable({ providedIn: 'root' })
+export class TodosHttpService {
+ private http: HttpClient = inject(HttpClient);
+
+ private host = 'https://jsonplaceholder.typicode.com/todos';
+
+ public getAll(): Observable {
+ return this.http.get(this.host);
+ }
+
+ public update(todo: ITodo): Observable {
+ return this.http.put(
+ `${this.host}/${todo.id}`,
+ JSON.stringify({
+ ...todo,
+ title: randText(),
+ }),
+ {
+ headers: {
+ 'Content-type': 'application/json; charset=UTF-8',
+ },
+ },
+ );
+ }
+
+ public delete(id: number): Observable {
+ return this.http.delete(`${this.host}/${id}`);
+ }
+}
diff --git a/apps/angular/5-crud-application/src/app/todo.store.spec.ts b/apps/angular/5-crud-application/src/app/todo.store.spec.ts
new file mode 100644
index 000000000..a849db6d8
--- /dev/null
+++ b/apps/angular/5-crud-application/src/app/todo.store.spec.ts
@@ -0,0 +1,89 @@
+import { TestBed } from '@angular/core/testing';
+import { Observable, of, throwError } from 'rxjs';
+import { ITodo } from './app.interface';
+import { TodosHttpService } from './http.service';
+import { TodoStore } from './todo.store';
+
+const firstTodo: ITodo = {
+ id: 1,
+ userId: 1,
+ title: 'First todo',
+ completed: false,
+};
+
+const secondTodo: ITodo = {
+ id: 2,
+ userId: 1,
+ title: 'Second todo',
+ completed: true,
+};
+
+describe('TodoStore', () => {
+ let store: TodoStore;
+ let httpService: {
+ getAll: jest.Mock, []>;
+ update: jest.Mock, [ITodo]>;
+ delete: jest.Mock, [number]>;
+ };
+
+ beforeEach(() => {
+ httpService = {
+ getAll: jest.fn(),
+ update: jest.fn(),
+ delete: jest.fn(),
+ };
+
+ TestBed.configureTestingModule({
+ providers: [
+ TodoStore,
+ { provide: TodosHttpService, useValue: httpService },
+ ],
+ });
+
+ store = TestBed.inject(TodoStore);
+ });
+
+ it('loads todos and clears the loading state', () => {
+ httpService.getAll.mockReturnValue(of([firstTodo, secondTodo]));
+
+ store.getAll();
+
+ expect(httpService.getAll).toHaveBeenCalledTimes(1);
+ expect(store.todos()).toEqual([firstTodo, secondTodo]);
+ expect(store.isLoading()).toBe(false);
+ });
+
+ it('updates the matching todo and keeps the other todos', () => {
+ const updatedTodo = { ...firstTodo, title: 'Updated todo' };
+ store.todos.set([firstTodo, secondTodo]);
+ httpService.update.mockReturnValue(of(updatedTodo));
+
+ store.updateTodo(firstTodo);
+
+ expect(httpService.update).toHaveBeenCalledWith(firstTodo);
+ expect(store.todos()).toEqual([updatedTodo, secondTodo]);
+ expect(store.isLoading()).toBe(false);
+ });
+
+ it('deletes a todo and keeps the remaining todos', () => {
+ store.todos.set([firstTodo, secondTodo]);
+ httpService.delete.mockReturnValue(of({}));
+
+ store.deleteOne(firstTodo.id);
+
+ expect(httpService.delete).toHaveBeenCalledWith(firstTodo.id);
+ expect(store.todos()).toEqual([secondTodo]);
+ expect(store.isLoading()).toBe(false);
+ });
+
+ it('logs an error when loading todos fails', () => {
+ const error = new Error('Request failed');
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
+ httpService.getAll.mockReturnValue(throwError(() => error));
+
+ store.getAll();
+
+ expect(consoleErrorSpy).toHaveBeenCalledWith('An error occurred: ', error);
+ consoleErrorSpy.mockRestore();
+ });
+});
diff --git a/apps/angular/5-crud-application/src/app/todo.store.ts b/apps/angular/5-crud-application/src/app/todo.store.ts
new file mode 100644
index 000000000..612285337
--- /dev/null
+++ b/apps/angular/5-crud-application/src/app/todo.store.ts
@@ -0,0 +1,66 @@
+import { inject, Injectable, signal, WritableSignal } from '@angular/core';
+import { finalize } from 'rxjs';
+import { ITodo } from './app.interface';
+import { TodosHttpService } from './http.service';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class TodoStore {
+ private todosHttpService: TodosHttpService = inject(TodosHttpService);
+
+ public todos: WritableSignal = signal([]);
+ public isLoading: WritableSignal = signal(false);
+
+ public getAll(): void {
+ this.isLoading.set(true);
+
+ this.todosHttpService
+ .getAll()
+ .pipe(finalize(() => this.isLoading.set(false)))
+ .subscribe({
+ next: (todos: ITodo[]) => this.todos.set(todos),
+ error: (err) => {
+ console.error('An error occurred: ', err);
+ },
+ });
+ }
+
+ public updateTodo(todo: ITodo): void {
+ this.isLoading.set(true);
+
+ this.todosHttpService
+ .update(todo)
+ .pipe(finalize(() => this.isLoading.set(false)))
+ .subscribe({
+ next: (updatedTodo: ITodo) => {
+ this.todos.update((todos) =>
+ todos.reduce(
+ (updatedTodos, currentTodo) => [
+ ...updatedTodos,
+ currentTodo.id === updatedTodo.id ? updatedTodo : currentTodo,
+ ],
+ [],
+ ),
+ );
+ },
+ error: (err) => {
+ console.error('An error occurred: ', err);
+ },
+ });
+ }
+
+ public deleteOne(id: number) {
+ this.isLoading.set(true);
+
+ this.todosHttpService
+ .delete(id)
+ .pipe(finalize(() => this.isLoading.set(false)))
+ .subscribe({
+ next: () => this.todos.set(this.todos().filter((s) => s.id !== id)),
+ error: (err) => {
+ console.error('An error occurred: ', err);
+ },
+ });
+ }
+}