From 580c83e2b810b96f1dc91153babdd7231e9690a3 Mon Sep 17 00:00:00 2001 From: lukasss88 Date: Fri, 24 Oct 2025 11:59:56 +0200 Subject: [PATCH] feat: refactor crud --- .../src/app/app.component.test.ts | 102 +++++++++++++++++ .../src/app/app.component.ts | 59 ++++------ .../5-crud-application/src/app/app.config.ts | 8 +- .../src/app/components/todo/todo.component.ts | 57 ++++++++++ .../core/interceptors/error.interceptor.ts | 14 +++ .../core/interceptors/loader.interceptor.ts | 13 +++ .../src/app/core/services/loader.service.ts | 16 +++ .../src/app/shared/ui/loader.component.ts | 23 ++++ .../src/app/store/todo.store.ts | 105 ++++++++++++++++++ .../src/app/todo.service.ts | 34 ++++++ .../5-crud-application/src/app/todo.ts | 6 + .../5-crud-application/src/test-setup.ts | 4 +- package-lock.json | 19 ++++ package.json | 1 + 14 files changed, 423 insertions(+), 38 deletions(-) create mode 100644 apps/angular/5-crud-application/src/app/app.component.test.ts create mode 100644 apps/angular/5-crud-application/src/app/components/todo/todo.component.ts create mode 100644 apps/angular/5-crud-application/src/app/core/interceptors/error.interceptor.ts create mode 100644 apps/angular/5-crud-application/src/app/core/interceptors/loader.interceptor.ts create mode 100644 apps/angular/5-crud-application/src/app/core/services/loader.service.ts create mode 100644 apps/angular/5-crud-application/src/app/shared/ui/loader.component.ts create mode 100644 apps/angular/5-crud-application/src/app/store/todo.store.ts create mode 100644 apps/angular/5-crud-application/src/app/todo.service.ts create mode 100644 apps/angular/5-crud-application/src/app/todo.ts diff --git a/apps/angular/5-crud-application/src/app/app.component.test.ts b/apps/angular/5-crud-application/src/app/app.component.test.ts new file mode 100644 index 000000000..2172dd945 --- /dev/null +++ b/apps/angular/5-crud-application/src/app/app.component.test.ts @@ -0,0 +1,102 @@ +import { + render, + screen, + waitForElementToBeRemoved, + within, +} from '@testing-library/angular'; +import userEvent from '@testing-library/user-event'; +import { of, Subject } from 'rxjs'; +import { AppComponent } from './app.component'; +import { Todo } from './todo'; +import { TodoService } from './todo.service'; + +const todos: Todo[] = [ + { + id: 1, + title: 'Todo 1', + completed: false, + userId: 0, + }, + { + id: 2, + title: 'Todo 2', + completed: true, + userId: 0, + }, +]; + +describe('AppComponent', () => { + let updateTodoMock: jest.Mock; + let deleteTodoMock: jest.Mock; + + beforeEach(async () => { + updateTodoMock = jest.fn(); + deleteTodoMock = jest.fn(); + + const mockTodoService: Partial = { + getTodos: jest.fn().mockReturnValue(of(todos)), + updateTodo: updateTodoMock, + deleteTodo: deleteTodoMock, + }; + await render(AppComponent, { + providers: [{ provide: TodoService, useValue: mockTodoService }], + }); + }); + it('should renders todos', async () => { + expect(screen.getByText('Todo 1')).toBeInTheDocument(); + expect(screen.getByText('Todo 2')).toBeInTheDocument(); + expect(screen.getAllByTestId('todo-item').length).toBe(2); + }); + + it('should update a todo', async () => { + const update$ = new Subject(); + updateTodoMock.mockReturnValue(update$); + + const firstRow = (await screen.findAllByTestId('todo-item')).find((el) => + el.textContent?.includes('Todo 1'), + )!; + const rowContainer = firstRow.closest('.container')! as HTMLElement; + + await userEvent.click(within(rowContainer).getByTestId('update-btn')); + + expect( + screen.getByTestId(`todo-spinner-${todos[0].id}`), + ).toBeInTheDocument(); + expect(updateTodoMock).toHaveBeenCalledWith(todos[0]); + expect(within(rowContainer).getByTestId('update-btn')).toBeDisabled(); + expect(within(rowContainer).getByTestId('delete-btn')).toBeDisabled(); + + update$.next({ ...todos[0], title: 'Todo 1 (updated)' }); + update$.complete(); + + expect(await screen.findByText('Todo 1 (updated)')).toBeInTheDocument(); + expect(screen.queryByTestId('todo-spinner-1')).not.toBeInTheDocument(); + expect(within(rowContainer).getByTestId('update-btn')).not.toBeDisabled(); + expect(within(rowContainer).getByTestId('delete-btn')).not.toBeDisabled(); + }); + + it('should remove a todo', async () => { + const delete$ = new Subject(); + deleteTodoMock.mockReturnValue(delete$); + + const secondRow = (await screen.findAllByTestId('todo-item')).find((el) => + el.textContent?.includes('Todo 2'), + )!; + const rowContainer = secondRow.closest('.container')! as HTMLElement; + + await userEvent.click(within(rowContainer).getByTestId('delete-btn')); + + expect( + screen.getByTestId(`todo-spinner-${todos[1].id}`), + ).toBeInTheDocument(); + expect(deleteTodoMock).toHaveBeenCalledWith(todos[1].id); + expect(within(rowContainer).getByTestId('update-btn')).toBeDisabled(); + expect(within(rowContainer).getByTestId('delete-btn')).toBeDisabled(); + + delete$.next(); + delete$.complete(); + + await waitForElementToBeRemoved(() => screen.getByTestId('todo-spinner-2')); + expect(screen.getAllByTestId('todo-item').length).toBe(1); + }); +}); 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..533d55e3c 100644 --- a/apps/angular/5-crud-application/src/app/app.component.ts +++ b/apps/angular/5-crud-application/src/app/app.component.ts @@ -1,49 +1,38 @@ -import { HttpClient } from '@angular/common/http'; -import { Component, inject, OnInit } from '@angular/core'; -import { randText } from '@ngneat/falso'; +import { Component, inject } from '@angular/core'; +import { TodoComponent } from './components/todo/todo.component'; +import { LoaderComponent } from './shared/ui/loader.component'; +import { TodoStore } from './store/todo.store'; +import { Todo } from './todo'; @Component({ - imports: [], + imports: [LoaderComponent, TodoComponent], selector: 'app-root', template: ` - @for (todo of todos; track todo.id) { - {{ todo.title }} - + + @for (todo of todos(); track todo.id) { + } `, styles: [], }) -export class AppComponent implements OnInit { - private http = inject(HttpClient); +export class AppComponent { + readonly store = inject(TodoStore); - todos!: any[]; + todos = this.store.todos; - ngOnInit(): void { - this.http - .get('https://jsonplaceholder.typicode.com/todos') - .subscribe((todos) => { - this.todos = todos; - }); + update(todo: Todo): void { + this.store.updateTodo(todo); } - 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; - }); + remove(id: number): void { + this.store.removeTodo(id); + } + + isProcessing(id: number) { + return this.store.isProcessing()(id); } } 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..0a9a65394 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,10 @@ -import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { ApplicationConfig } from '@angular/core'; +import { errorInterceptor } from './core/interceptors/error.interceptor'; +import { loaderInterceptor } from './core/interceptors/loader.interceptor'; export const appConfig: ApplicationConfig = { - providers: [provideHttpClient()], + providers: [ + provideHttpClient(withInterceptors([errorInterceptor, loaderInterceptor])), + ], }; diff --git a/apps/angular/5-crud-application/src/app/components/todo/todo.component.ts b/apps/angular/5-crud-application/src/app/components/todo/todo.component.ts new file mode 100644 index 000000000..adeeb5827 --- /dev/null +++ b/apps/angular/5-crud-application/src/app/components/todo/todo.component.ts @@ -0,0 +1,57 @@ +import { Component, input, output } from '@angular/core'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { Todo } from '../../todo'; + +@Component({ + selector: 'app-todo', + template: ` +
+ {{ todo().title }} + @if (isProccessing()) { + + + + } + + +
+ `, + imports: [MatProgressSpinnerModule], + styles: ` + .container { + display: flex; + } + + .inline-spinner { + display: inline-flex; + vertical-align: middle; + margin-left: 4px; + } + `, +}) +export class TodoComponent { + todo = input.required(); + isProccessing = input(false); + onUpdate = output(); + onRemove = output(); + + update(todo: Todo) { + this.onUpdate.emit(todo); + } + + remove(id: number) { + this.onRemove.emit(id); + } +} diff --git a/apps/angular/5-crud-application/src/app/core/interceptors/error.interceptor.ts b/apps/angular/5-crud-application/src/app/core/interceptors/error.interceptor.ts new file mode 100644 index 000000000..c2d97ea12 --- /dev/null +++ b/apps/angular/5-crud-application/src/app/core/interceptors/error.interceptor.ts @@ -0,0 +1,14 @@ +import { HttpEvent, HttpHandlerFn, HttpRequest } from '@angular/common/http'; +import { catchError, Observable, throwError } from 'rxjs'; + +export function errorInterceptor( + req: HttpRequest, + next: HttpHandlerFn, +): Observable> { + return next(req).pipe( + catchError((error) => { + console.error('HTTP Error:', error); + return throwError(() => new Error(error)); + }), + ); +} diff --git a/apps/angular/5-crud-application/src/app/core/interceptors/loader.interceptor.ts b/apps/angular/5-crud-application/src/app/core/interceptors/loader.interceptor.ts new file mode 100644 index 000000000..27a246707 --- /dev/null +++ b/apps/angular/5-crud-application/src/app/core/interceptors/loader.interceptor.ts @@ -0,0 +1,13 @@ +import { HttpEvent, HttpHandlerFn, HttpRequest } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { finalize, Observable } from 'rxjs'; +import { LoaderService } from '../services/loader.service'; + +export function loaderInterceptor( + req: HttpRequest, + next: HttpHandlerFn, +): Observable> { + const loaderService = inject(LoaderService); + loaderService.show(); + return next(req).pipe(finalize(() => loaderService.hide())); +} diff --git a/apps/angular/5-crud-application/src/app/core/services/loader.service.ts b/apps/angular/5-crud-application/src/app/core/services/loader.service.ts new file mode 100644 index 000000000..41069f63b --- /dev/null +++ b/apps/angular/5-crud-application/src/app/core/services/loader.service.ts @@ -0,0 +1,16 @@ +import { Injectable, signal } from '@angular/core'; + +@Injectable({ + providedIn: 'root', +}) +export class LoaderService { + _isLoading = signal(false); + isLoading = this._isLoading.asReadonly(); + + show() { + this._isLoading.set(true); + } + hide() { + this._isLoading.set(false); + } +} diff --git a/apps/angular/5-crud-application/src/app/shared/ui/loader.component.ts b/apps/angular/5-crud-application/src/app/shared/ui/loader.component.ts new file mode 100644 index 000000000..11a0b082c --- /dev/null +++ b/apps/angular/5-crud-application/src/app/shared/ui/loader.component.ts @@ -0,0 +1,23 @@ +import { CommonModule } from '@angular/common'; +import { Component, inject } from '@angular/core'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { LoaderService } from '../../core/services/loader.service'; + +@Component({ + selector: 'app-loader', + imports: [CommonModule, MatProgressSpinnerModule], + template: ` + @if (isLoading()) { +
+
+ +
+
+ } + `, +}) +export class LoaderComponent { + loaderService = inject(LoaderService); + + isLoading = this.loaderService.isLoading; +} diff --git a/apps/angular/5-crud-application/src/app/store/todo.store.ts b/apps/angular/5-crud-application/src/app/store/todo.store.ts new file mode 100644 index 000000000..1b10978dc --- /dev/null +++ b/apps/angular/5-crud-application/src/app/store/todo.store.ts @@ -0,0 +1,105 @@ +import { inject } from '@angular/core'; +import { + patchState, + signalStore, + withComputed, + withHooks, + withMethods, + withState, +} from '@ngrx/signals'; +import { rxMethod } from '@ngrx/signals/rxjs-interop'; +import { finalize, Observable, pipe, switchMap, tap } from 'rxjs'; +import { Todo } from '../todo'; +import { TodoService } from '../todo.service'; + +type TodoState = { + todos: Array; + processingIds: Array; +}; + +const initialState: TodoState = { + todos: [], + processingIds: [], +}; + +function addId(arr: number[], id: number) { + return arr.includes(id) ? arr : [...arr, id]; +} + +function removeId(arr: number[], id: number) { + return arr.filter((x) => x !== id); +} + +export const TodoStore = signalStore( + { providedIn: 'root' }, + withState(initialState), + withMethods((store, todoService = inject(TodoService)) => { + const loadTodos = rxMethod( + pipe( + switchMap(() => todoService.getTodos()), + tap((todos) => patchState(store, { todos })), + ), + ); + + const processTodo = ( + id: number, + action$: (id: number) => Observable, + after: (res: T) => void, + ) => { + patchState(store, (s) => ({ processingIds: addId(s.processingIds, id) })); + + return action$(id).pipe( + tap((result) => after(result)), + finalize(() => + patchState(store, (s) => ({ + processingIds: removeId(s.processingIds, id), + })), + ), + ); + }; + + const updateTodo = rxMethod( + pipe( + switchMap((todo) => + processTodo( + todo.id, + () => todoService.updateTodo(todo), + (updated) => + patchState(store, (s) => ({ + todos: s.todos.map((t) => (t.id === updated.id ? updated : t)), + })), + ), + ), + ), + ); + + const removeTodo = rxMethod( + pipe( + switchMap((id) => + processTodo( + id, + (id) => todoService.deleteTodo(id), + () => + patchState(store, (s) => ({ + todos: s.todos.filter((t) => t.id !== id), + })), + ), + ), + ), + ); + + return { + loadTodos, + updateTodo, + removeTodo, + }; + }), + withComputed((store) => ({ + isProcessing: () => (id: number) => store.processingIds().includes(id), + })), + withHooks({ + onInit(store) { + store.loadTodos(); + }, + }), +); diff --git a/apps/angular/5-crud-application/src/app/todo.service.ts b/apps/angular/5-crud-application/src/app/todo.service.ts new file mode 100644 index 000000000..6369f2dab --- /dev/null +++ b/apps/angular/5-crud-application/src/app/todo.service.ts @@ -0,0 +1,34 @@ +import { HttpClient } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; +import { randText } from '@ngneat/falso'; +import { Observable } from 'rxjs'; +import { Todo } from './todo'; + +@Injectable({ + providedIn: 'root', +}) +export class TodoService { + private apiUrl = 'https://jsonplaceholder.typicode.com/todos'; + http = inject(HttpClient); + + getTodos(): Observable { + return this.http.get(this.apiUrl); + } + + addTodo(todo: Todo): Observable { + return this.http.post(this.apiUrl, todo); + } + + updateTodo(todo: Todo): Observable { + return this.http.put(`${this.apiUrl}/${todo.id}`, { + id: todo.id, + title: randText(), + completed: todo.completed, + userId: todo.userId, + }); + } + + deleteTodo(id: number): Observable { + return this.http.delete(`${this.apiUrl}/${id}`); + } +} diff --git a/apps/angular/5-crud-application/src/app/todo.ts b/apps/angular/5-crud-application/src/app/todo.ts new file mode 100644 index 000000000..780afae02 --- /dev/null +++ b/apps/angular/5-crud-application/src/app/todo.ts @@ -0,0 +1,6 @@ +export interface Todo { + id: number; + title: string; + completed: boolean; + userId: number; +} diff --git a/apps/angular/5-crud-application/src/test-setup.ts b/apps/angular/5-crud-application/src/test-setup.ts index 15de72a3c..9d9196920 100644 --- a/apps/angular/5-crud-application/src/test-setup.ts +++ b/apps/angular/5-crud-application/src/test-setup.ts @@ -1,2 +1,4 @@ import '@testing-library/jest-dom'; -import 'jest-preset-angular/setup-jest'; +import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'; + +setupZoneTestEnv(); diff --git a/package-lock.json b/package-lock.json index 4eefb40e0..5a2ba8ce7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "@ngneat/falso": "7.2.0", "@ngrx/component-store": "19.0.1", "@ngrx/operators": "19.0.1", + "@ngrx/signals": "^20.1.0", "@nx/angular": "21.2.1", "@swc/helpers": "0.5.12", "@tanstack/angular-query-experimental": "5.81.5", @@ -7557,6 +7558,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@ngrx/signals": { + "version": "20.1.0", + "resolved": "https://registry.npmjs.org/@ngrx/signals/-/signals-20.1.0.tgz", + "integrity": "sha512-ARAHp5yA131Sw6FEtY8XtYcdGcwW5lgpZaJoDIRxc6i12VO3ZDZYp3M/FQhpIDMDXkXHR+pDqoitrqvzI69aQA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/core": "^20.0.0", + "rxjs": "^6.5.3 || ^7.4.0" + }, + "peerDependenciesMeta": { + "rxjs": { + "optional": true + } + } + }, "node_modules/@ngtools/webpack": { "version": "20.0.5", "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-20.0.5.tgz", diff --git a/package.json b/package.json index 43df41e5b..42f0c1ea5 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@ngneat/falso": "7.2.0", "@ngrx/component-store": "19.0.1", "@ngrx/operators": "19.0.1", + "@ngrx/signals": "^20.1.0", "@nx/angular": "21.2.1", "@swc/helpers": "0.5.12", "@tanstack/angular-query-experimental": "5.81.5",