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
102 changes: 102 additions & 0 deletions apps/angular/5-crud-application/src/app/app.component.test.ts
Original file line number Diff line number Diff line change
@@ -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<TodoService> = {
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<Todo>();
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<void>();
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);
});
});
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice set of tests 🔥

59 changes: 24 additions & 35 deletions apps/angular/5-crud-application/src/app/app.component.ts
Original file line number Diff line number Diff line change
@@ -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 }}
<button (click)="update(todo)">Update</button>
<app-loader></app-loader>
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you have a global loader.

What is I want a loader located at the todo component level when I update the todo, or delete it ?

Copy link
Author

@lukasss88 lukasss88 Oct 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I implemented two kinds of loaders:

the global one:
<app-loader></app-loader>

and at the todo level as well:
@if (isProccessing()) { <span class="inline-spinner"> <mat-spinner [diameter]="16" [attr.data-testid]="'todo-spinner-' + todo().id" /> </span> }

@for (todo of todos(); track todo.id) {
<app-todo
[todo]="todo"
[isProccessing]="isProcessing(todo.id)"
(onUpdate)="update($event)"
(onRemove)="remove($event)" />
}
`,
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<any[]>('https://jsonplaceholder.typicode.com/todos')
.subscribe((todos) => {
this.todos = todos;
});
update(todo: Todo): void {
this.store.updateTodo(todo);
}

update(todo: any) {
this.http
.put<any>(
`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);
}
}
8 changes: 6 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,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])),
],
};
Original file line number Diff line number Diff line change
@@ -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: `
<div class="container">
<span data-testid="todo-item">{{ todo().title }}</span>
@if (isProccessing()) {
<span class="inline-spinner">
<mat-spinner
[diameter]="16"
[attr.data-testid]="'todo-spinner-' + todo().id" />
</span>
}
<button
data-testid="update-btn"
(click)="update(todo())"
[disabled]="isProccessing()">
Update
</button>
<button
data-testid="delete-btn"
(click)="remove(todo().id)"
[disabled]="isProccessing()">
Delete
</button>
</div>
`,
imports: [MatProgressSpinnerModule],
styles: `
.container {
display: flex;
}
.inline-spinner {
display: inline-flex;
vertical-align: middle;
margin-left: 4px;
}
`,
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: always use changedetection.onpush That's an easy win

})
export class TodoComponent {
todo = input.required<Todo>();
isProccessing = input(false);
onUpdate = output<Todo>();
onRemove = output<number>();

update(todo: Todo) {
this.onUpdate.emit(todo);
}

remove(id: number) {
this.onRemove.emit(id);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { HttpEvent, HttpHandlerFn, HttpRequest } from '@angular/common/http';
import { catchError, Observable, throwError } from 'rxjs';

export function errorInterceptor(
req: HttpRequest<any>,
next: HttpHandlerFn,
): Observable<HttpEvent<any>> {
return next(req).pipe(
catchError((error) => {
console.error('HTTP Error:', error);
return throwError(() => new Error(error));
}),
);
}
Original file line number Diff line number Diff line change
@@ -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<any>,
next: HttpHandlerFn,
): Observable<HttpEvent<any>> {
const loaderService = inject(LoaderService);
loaderService.show();
return next(req).pipe(finalize(() => loaderService.hide()));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Injectable, signal } from '@angular/core';

@Injectable({
providedIn: 'root',
})
export class LoaderService {
_isLoading = signal<boolean>(false);
isLoading = this._isLoading.asReadonly();

show() {
this._isLoading.set(true);
}
hide() {
this._isLoading.set(false);
}
}
Original file line number Diff line number Diff line change
@@ -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()) {
<div class="loader-backdrop">
<div class="loader-spinner">
<mat-spinner></mat-spinner>
</div>
</div>
}
`,
})
export class LoaderComponent {
loaderService = inject(LoaderService);

isLoading = this.loaderService.isLoading;
}
Loading