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" /> - + diff --git a/apps/angular/5-crud-application/src/main.ts b/apps/angular/5-crud-application/src/main.ts index 866d45959..9dfadcf4c 100644 --- a/apps/angular/5-crud-application/src/main.ts +++ b/apps/angular/5-crud-application/src/main.ts @@ -2,9 +2,9 @@ import { provideZoneChangeDetection } from '@angular/core'; import { appConfig } from './app/app.config'; import { bootstrapApplication } from '@angular/platform-browser'; -import { AppComponent } from './app/app.component'; +import { TodoComponent } from './app/todo/todo.component'; -bootstrapApplication(AppComponent, { +bootstrapApplication(TodoComponent, { ...appConfig, providers: [provideZoneChangeDetection(), ...appConfig.providers], }).catch((err) => console.error(err)); diff --git a/apps/angular/5-crud-application/src/styles.scss b/apps/angular/5-crud-application/src/styles.scss index 5fb5bb00b..d5172da86 100644 --- a/apps/angular/5-crud-application/src/styles.scss +++ b/apps/angular/5-crud-application/src/styles.scss @@ -8,3 +8,9 @@ body { margin: 0; font-family: Roboto, 'Helvetica Neue', sans-serif; } + +app-todo { + display: flex; + flex-direction: column; + gap: 1rem; +}