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); + }, + }); + } +}