From 7ae491eb0eab0f9ea0aa190059fe3e93e93ca942 Mon Sep 17 00:00:00 2001 From: jpaberzs Date: Tue, 25 Aug 2026 21:14:49 +0300 Subject: [PATCH 1/2] feat: created todo app with existed backend --- .../src/app/app.component.ts | 59 ++++++------ .../src/app/app.interface.ts | 6 ++ .../src/app/http.service.ts | 35 ++++++++ .../src/app/todo.store.spec.ts | 89 +++++++++++++++++++ .../5-crud-application/src/app/todo.store.ts | 65 ++++++++++++++ 5 files changed, 220 insertions(+), 34 deletions(-) create mode 100644 apps/angular/5-crud-application/src/app/app.interface.ts create mode 100644 apps/angular/5-crud-application/src/app/http.service.ts create mode 100644 apps/angular/5-crud-application/src/app/todo.store.spec.ts create mode 100644 apps/angular/5-crud-application/src/app/todo.store.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 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..9306bf5a4 --- /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: string = '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..dfbeac773 --- /dev/null +++ b/apps/angular/5-crud-application/src/app/todo.store.ts @@ -0,0 +1,65 @@ +import { inject, Injectable, signal, WritableSignal } from '@angular/core'; +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().subscribe({ + next: (todos: ITodo[]) => this.todos.set(todos), + error: (err) => { + console.error('An error occurred: ', err); + }, + complete: () => { + this.isLoading.set(false); + }, + }); + } + + public updateTodo(todo: ITodo): void { + this.isLoading.set(true); + + this.todosHttpService.update(todo).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); + }, + complete: () => { + this.isLoading.set(false); + }, + }); + } + + public deleteOne(id: number) { + this.isLoading.set(true); + + this.todosHttpService.delete(id).subscribe({ + next: () => this.todos.set(this.todos().filter((s) => s.id !== id)), + error: (err) => { + console.error('An error occurred: ', err); + }, + complete: () => { + this.isLoading.set(false); + }, + }); + } +} From 2a4b156e520470a2c74c29c8acbb5675caa95dd9 Mon Sep 17 00:00:00 2001 From: jpaberzs Date: Tue, 25 Aug 2026 21:31:40 +0300 Subject: [PATCH 2/2] fix: fixed comments from pull request --- .../src/app/http.service.ts | 4 +- .../5-crud-application/src/app/todo.store.ts | 75 ++++++++++--------- 2 files changed, 40 insertions(+), 39 deletions(-) diff --git a/apps/angular/5-crud-application/src/app/http.service.ts b/apps/angular/5-crud-application/src/app/http.service.ts index 9306bf5a4..ba1e591ee 100644 --- a/apps/angular/5-crud-application/src/app/http.service.ts +++ b/apps/angular/5-crud-application/src/app/http.service.ts @@ -8,7 +8,7 @@ import { ITodo } from './app.interface'; export class TodosHttpService { private http: HttpClient = inject(HttpClient); - private host: string = 'https://jsonplaceholder.typicode.com/todos'; + private host = 'https://jsonplaceholder.typicode.com/todos'; public getAll(): Observable { return this.http.get(this.host); @@ -29,7 +29,7 @@ export class TodosHttpService { ); } - public delete(id: number): Observable { + public delete(id: number): Observable { return this.http.delete(`${this.host}/${id}`); } } diff --git a/apps/angular/5-crud-application/src/app/todo.store.ts b/apps/angular/5-crud-application/src/app/todo.store.ts index dfbeac773..612285337 100644 --- a/apps/angular/5-crud-application/src/app/todo.store.ts +++ b/apps/angular/5-crud-application/src/app/todo.store.ts @@ -1,4 +1,5 @@ import { inject, Injectable, signal, WritableSignal } from '@angular/core'; +import { finalize } from 'rxjs'; import { ITodo } from './app.interface'; import { TodosHttpService } from './http.service'; @@ -14,52 +15,52 @@ export class TodoStore { public getAll(): void { this.isLoading.set(true); - this.todosHttpService.getAll().subscribe({ - next: (todos: ITodo[]) => this.todos.set(todos), - error: (err) => { - console.error('An error occurred: ', err); - }, - complete: () => { - this.isLoading.set(false); - }, - }); + 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).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); - }, - complete: () => { - this.isLoading.set(false); - }, - }); + 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).subscribe({ - next: () => this.todos.set(this.todos().filter((s) => s.id !== id)), - error: (err) => { - console.error('An error occurred: ', err); - }, - complete: () => { - this.isLoading.set(false); - }, - }); + 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); + }, + }); } }