-
-
Notifications
You must be signed in to change notification settings - Fork 2.6k
Answer:5 #1382
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Answer:5 #1382
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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); | ||
| }); | ||
| }); | ||
| 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> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I implemented two kinds of loaders: the global one: and at the todo level as well: |
||
| @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); | ||
| } | ||
| } | ||
| 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; | ||
| } | ||
| `, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nitpick: always use |
||
| }) | ||
| 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; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nice set of tests 🔥