Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 0 additions & 49 deletions apps/angular/5-crud-application/src/app/app.component.ts

This file was deleted.

5 changes: 3 additions & 2 deletions apps/angular/5-crud-application/src/app/app.config.ts
Original file line number Diff line number Diff line change
@@ -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]))],
};
20 changes: 20 additions & 0 deletions apps/angular/5-crud-application/src/app/http-error.interceptor.ts
Original file line number Diff line number Diff line change
@@ -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'));
}
}),
);
};
Original file line number Diff line number Diff line change
@@ -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: `
<div [class.processing]="isProcessing">
{{ todo.title }}
<br />
@if (isProcessing) {
<mat-spinner diameter="20"></mat-spinner>
}
@if (error) {
<div style="color: red;">{{ error }}</div>
}
<button (click)="onUpdate()">Update</button>
<button (click)="delete.emit(this.todo)">Delete</button>
</div>
`,
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<todo>();
@Output() delete = new EventEmitter<todo>();

onUpdate() {
this.update.emit(this.todo);
}
}
Original file line number Diff line number Diff line change
@@ -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<TodoComponent>;
let component: TodoComponent;
let todosSubject: Subject<todo[]>;
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<todo[]>();
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);
});
});
40 changes: 40 additions & 0 deletions apps/angular/5-crud-application/src/app/todo/todo.component.ts
Original file line number Diff line number Diff line change
@@ -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) {
<mat-spinner></mat-spinner>
} @else {
@for (todo of store.todos$ | async; track todo.id) {
<app-todo-item
[todo]="todo"
[isProcessing]="(store.isTodoProcessing$(todo.id) | async) ?? false"
[error]="store.todoError$(todo.id) | async"
(update)="update($event)"
(delete)="delete($event)"></app-todo-item>
}
}
`,
})
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);
}
}
14 changes: 14 additions & 0 deletions apps/angular/5-crud-application/src/app/todo/todo.model.ts
Original file line number Diff line number Diff line change
@@ -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;
}
38 changes: 38 additions & 0 deletions apps/angular/5-crud-application/src/app/todo/todo.service.ts
Original file line number Diff line number Diff line change
@@ -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<todo[]>('https://jsonplaceholder.typicode.com/todos');
}

updateTodo(todo: todo) {
return this.http.put<todo>(
`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}`,
);
}
}
8 changes: 8 additions & 0 deletions apps/angular/5-crud-application/src/app/todo/todo.state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { todo } from './todo.model';

export interface TodoState {
todos: todo[];
isLoading: boolean;
processingIds: Set<number>;
errors: Record<number, string | null>;
}
Loading