diff --git a/apps/angular/crud/src/app/app.component.ts b/apps/angular/crud/src/app/app.component.ts index 8c3d1b8ae..07c240ca4 100644 --- a/apps/angular/crud/src/app/app.component.ts +++ b/apps/angular/crud/src/app/app.component.ts @@ -1,51 +1,21 @@ import { CommonModule } from '@angular/common'; -import { HttpClient } from '@angular/common/http'; -import { Component, OnInit } from '@angular/core'; -import { randText } from '@ngneat/falso'; +import { Component } from '@angular/core'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { injectQueryClient } from '@tanstack/angular-query-experimental'; +import { TodosComponent } from './components/todos/todos.component'; @Component({ standalone: true, - imports: [CommonModule], + imports: [CommonModule, MatProgressBarModule, TodosComponent], selector: 'app-root', template: ` -
- {{ todo.title }} - -
+ @if (queryClient.isFetching()) { + + } + `, styles: [], }) -export class AppComponent implements OnInit { - todos!: any[]; - - constructor(private http: HttpClient) {} - - 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; - }); - } +export class AppComponent { + public queryClient = injectQueryClient(); } diff --git a/apps/angular/crud/src/app/app.config.ts b/apps/angular/crud/src/app/app.config.ts index de0a3ccec..796bc205c 100644 --- a/apps/angular/crud/src/app/app.config.ts +++ b/apps/angular/crud/src/app/app.config.ts @@ -1,5 +1,20 @@ import { HttpClientModule } from '@angular/common/http'; -import { ApplicationConfig, importProvidersFrom } from '@angular/core'; +import { + ApplicationConfig, + ErrorHandler, + importProvidersFrom, +} from '@angular/core'; +import { ErrorHandlerService } from './services/error-handler.service'; + +import { + provideAngularQuery, + QueryClient, +} from '@tanstack/angular-query-experimental'; + export const appConfig: ApplicationConfig = { - providers: [importProvidersFrom(HttpClientModule)], + providers: [ + importProvidersFrom(HttpClientModule), + provideAngularQuery(new QueryClient()), + { provide: ErrorHandler, useClass: ErrorHandlerService }, + ], }; diff --git a/apps/angular/crud/src/app/components/todos/todos.component.css b/apps/angular/crud/src/app/components/todos/todos.component.css new file mode 100644 index 000000000..6b61a4cb2 --- /dev/null +++ b/apps/angular/crud/src/app/components/todos/todos.component.css @@ -0,0 +1,8 @@ +.todos{ + list-style:decimal; +} +.todo{ + padding: 0.5rem; + border-bottom: 2px solid salmon; + width: fit-content; +} \ No newline at end of file diff --git a/apps/angular/crud/src/app/components/todos/todos.component.html b/apps/angular/crud/src/app/components/todos/todos.component.html new file mode 100644 index 000000000..ddb94754c --- /dev/null +++ b/apps/angular/crud/src/app/components/todos/todos.component.html @@ -0,0 +1,14 @@ +@if (todoStore.query.isFetched()) { +
    + @for (todo of todoStore.query.data(); track todo.title) { +
  1. + {{ todo.title }} + + +
  2. + } +
+} +@if(todoStore.query.error()){ +

An error occured..

+} \ No newline at end of file diff --git a/apps/angular/crud/src/app/components/todos/todos.component.spec.ts b/apps/angular/crud/src/app/components/todos/todos.component.spec.ts new file mode 100644 index 000000000..617ecd7c5 --- /dev/null +++ b/apps/angular/crud/src/app/components/todos/todos.component.spec.ts @@ -0,0 +1,56 @@ +// todos.component.spec.ts + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Todo } from '../../interfaces/todo.interface'; +import { OperationType } from '../../store/enums/actions.enum'; +import { TodoStore } from '../../store/todo/todo-store'; +import { TodosComponent } from './todos.component'; + +describe('TodosComponent', () => { + let component: TodosComponent; + let fixture: ComponentFixture; + const todoStoreMock = { + query: { + isFetched: jest.fn(), + data: jest.fn(), + error: jest.fn(), + }, + mutation: { + mutate: jest.fn(), + }, + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + providers: [{ provide: TodoStore, useValue: todoStoreMock }], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(TodosComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should call update method with correct payload', () => { + const todo: Todo = { id: 1, title: 'Todo 1' }; + component.update(todo); + expect(todoStoreMock.mutation.mutate).toHaveBeenCalledWith({ + type: OperationType.UPDATE, + payload: { ...todo, title: expect.any(String) }, + }); + }); + + it('should call delete method with correct payload', () => { + const todoId = 1; + component.delete(todoId); + expect(todoStoreMock.mutation.mutate).toHaveBeenCalledWith({ + type: OperationType.DELETE, + payload: todoId, + }); + }); +}); diff --git a/apps/angular/crud/src/app/components/todos/todos.component.ts b/apps/angular/crud/src/app/components/todos/todos.component.ts new file mode 100644 index 000000000..b90319a2c --- /dev/null +++ b/apps/angular/crud/src/app/components/todos/todos.component.ts @@ -0,0 +1,27 @@ +import { Component, inject } from '@angular/core'; +import { randText } from '@ngneat/falso'; +import { Todo } from '../../interfaces/todo.interface'; +import { OperationType } from '../../store/enums/actions.enum'; +import { TodoStore } from '../../store/todo/todo-store'; + +@Component({ + selector: 'app-todos', + standalone: true, + templateUrl: './todos.component.html', + styleUrl: './todos.component.css', +}) +export class TodosComponent { + todoStore = inject(TodoStore); + + update(todo: Todo) { + todo = { ...todo, title: randText() }; + this.todoStore.mutation.mutate({ + type: OperationType.UPDATE, + payload: todo, + }); + } + + delete(id: number) { + this.todoStore.mutation.mutate({ type: OperationType.DELETE, payload: id }); + } +} diff --git a/apps/angular/crud/src/app/interfaces/todo.interface.ts b/apps/angular/crud/src/app/interfaces/todo.interface.ts new file mode 100644 index 000000000..2a4adb4d6 --- /dev/null +++ b/apps/angular/crud/src/app/interfaces/todo.interface.ts @@ -0,0 +1,6 @@ +export interface Todo { + id: number; + title: string; + completed?: boolean; + userId?: number; +} diff --git a/apps/angular/crud/src/app/services/error-handler.service.ts b/apps/angular/crud/src/app/services/error-handler.service.ts new file mode 100644 index 000000000..8a0d946a4 --- /dev/null +++ b/apps/angular/crud/src/app/services/error-handler.service.ts @@ -0,0 +1,10 @@ +import { ErrorHandler, Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root', +}) +export class ErrorHandlerService implements ErrorHandler { + handleError(error: unknown): void { + console.error(error); + } +} diff --git a/apps/angular/crud/src/app/services/todo.service.spec.ts b/apps/angular/crud/src/app/services/todo.service.spec.ts new file mode 100644 index 000000000..d824713f9 --- /dev/null +++ b/apps/angular/crud/src/app/services/todo.service.spec.ts @@ -0,0 +1,89 @@ +import { HttpClient } from '@angular/common/http'; +import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { delay } from 'rxjs/operators'; +import { Todo } from '../interfaces/todo.interface'; +import { TodoService } from './todo.service'; + +class MockHttpClient { + get = jest.fn(); + put = jest.fn(); + delete = jest.fn(); +} +const httpClientMock = new MockHttpClient(); +const BASE_URL = 'api_url'; + +describe('TodoService', () => { + let service: TodoService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + TodoService, + { provide: HttpClient, useValue: httpClientMock }, + ], + }); + service = TestBed.inject(TodoService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('get method', () => { + it('should return observable of Todo array', () => { + const dummyTodos = [ + { id: 1, title: 'Todo 1' }, + { id: 2, title: 'Todo 2' }, + ]; + httpClientMock.get.mockReturnValue(of(dummyTodos)); + service.get().subscribe((todos) => { + expect(todos).toEqual(dummyTodos); + }); + }); + + it('should delay response by 1 second', () => { + const dummyTodos = [ + { id: 1, title: 'Todo 1' }, + { id: 2, title: 'Todo 2' }, + ]; + httpClientMock.get.mockReturnValue(of(dummyTodos).pipe(delay(1000))); + const start = Date.now(); + service.get().subscribe(() => { + const end = Date.now(); + expect(end - start).toBeGreaterThanOrEqual(1000); + }); + }); + }); + + describe('update method', () => { + it('should call HttpClient.put with correct URL and payload', () => { + const todo: Todo = { + id: 1, + title: 'Updated Todo', + completed: false, + userId: 1, + }; + httpClientMock.put.mockReturnValue(of(todo)); + service.update(todo).subscribe(() => { + expect(httpClientMock.put).toHaveBeenCalledWith( + `${BASE_URL}/${todo.id}`, + JSON.stringify(todo), + { headers: { 'Content-type': 'application/json; charset=UTF-8' } }, + ); + }); + }); + }); + + describe('delete method', () => { + it('should call HttpClient.delete with correct URL', () => { + const todoId = 1; + httpClientMock.delete.mockReturnValue(of(null)); + service.delete(todoId).subscribe(() => { + expect(httpClientMock.delete).toHaveBeenCalledWith( + `${BASE_URL}/${todoId.toString()}`, + ); + }); + }); + }); +}); diff --git a/apps/angular/crud/src/app/services/todo.service.ts b/apps/angular/crud/src/app/services/todo.service.ts new file mode 100644 index 000000000..b409a35d7 --- /dev/null +++ b/apps/angular/crud/src/app/services/todo.service.ts @@ -0,0 +1,35 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable, delay } from 'rxjs'; +import { Todo } from '../interfaces/todo.interface'; + +const BASE_URL = 'https://jsonplaceholder.typicode.com/todos'; +@Injectable({ + providedIn: 'root', +}) +export class TodoService { + private http = inject(HttpClient); + + get(): Observable { + // Added 1s fake delay + return this.http.get(BASE_URL).pipe(delay(1000)); + } + + update(todo: Todo) { + const headerOption = { + headers: { + 'Content-type': 'application/json; charset=UTF-8', + }, + }; + + return this.http.put( + `${BASE_URL}/${todo.id}`, + JSON.stringify(todo), + headerOption, + ); + } + + delete(todoId: number) { + return this.http.delete(`${BASE_URL}/${todoId.toString()}`); + } +} diff --git a/apps/angular/crud/src/app/store/enums/actions.enum.ts b/apps/angular/crud/src/app/store/enums/actions.enum.ts new file mode 100644 index 000000000..2b70229e9 --- /dev/null +++ b/apps/angular/crud/src/app/store/enums/actions.enum.ts @@ -0,0 +1,5 @@ +export enum OperationType { + UPDATE = 'UPDATE', + DELETE = 'DELETE', + GET = 'GET', +} diff --git a/apps/angular/crud/src/app/store/todo/todo-args.ts b/apps/angular/crud/src/app/store/todo/todo-args.ts new file mode 100644 index 000000000..e9b52651a --- /dev/null +++ b/apps/angular/crud/src/app/store/todo/todo-args.ts @@ -0,0 +1,19 @@ +import { Todo } from '../../interfaces/todo.interface'; +import { OperationType } from '../enums/actions.enum'; + +export interface TodoUpdateArgs { + type: OperationType.UPDATE; + payload: Todo; +} + +export interface TodoDeleteArgs { + type: OperationType.DELETE; + payload: number; +} + +export interface TodoGethArgs { + type: OperationType.GET; +} + +// Define a union type combining both interfaces +export type TodoStateArgs = TodoDeleteArgs | TodoUpdateArgs | TodoGethArgs; diff --git a/apps/angular/crud/src/app/store/todo/todo-store.ts b/apps/angular/crud/src/app/store/todo/todo-store.ts new file mode 100644 index 000000000..3b2ff4c7b --- /dev/null +++ b/apps/angular/crud/src/app/store/todo/todo-store.ts @@ -0,0 +1,77 @@ +import { Injectable, inject } from '@angular/core'; + +import { + injectMutation, + injectQuery, + injectQueryClient, +} from '@tanstack/angular-query-experimental'; +import { Todo } from '../../interfaces/todo.interface'; +import { TodoService } from '../../services/todo.service'; +import { OperationType } from '../enums/actions.enum'; +import { TodoStateArgs } from './todo-args'; +@Injectable({ + providedIn: 'root', +}) +export class TodoStore { + private readonly QUERY_KEY = 'TODOS_STORE'; + private queryClient = injectQueryClient(); + private todoService = inject(TodoService); + + public query = injectQuery(() => ({ + queryKey: [this.QUERY_KEY], + queryFn: () => this.todoService.get().toPromise(), + })); + + mutation = injectMutation(() => ({ + mutationFn: async (args: TodoStateArgs) => { + switch (args.type) { + case OperationType.UPDATE: { + return await this.todoService.update(args.payload).toPromise(); + } + case OperationType.DELETE: { + return await this.todoService.delete(args.payload).toPromise(); + } + case OperationType.GET: { + return await this.todoService.get().toPromise(); + } + } + }, + onSuccess: (data, variables) => { + const currentTodos = + this.queryClient.getQueryData([this.QUERY_KEY]) ?? []; + switch (variables.type) { + case OperationType.DELETE: { + return this.queryClient.setQueryData( + [this.QUERY_KEY], + this.deleteTodo(currentTodos, variables.payload), + ); + } + case OperationType.UPDATE: { + return this.queryClient.setQueryData( + [this.QUERY_KEY], + this.updateTodo(currentTodos, variables.payload), + ); + } + case OperationType.GET: { + return this.queryClient.setQueryData([this.QUERY_KEY], data); + } + } + }, + onError: (error) => { + console.error(error); + }, + })); + + private deleteTodo(todos: Todo[], id: number) { + return todos.filter((todo) => todo.id !== id); + } + + private updateTodo(todos: Todo[], updatedTodo: Todo) { + return todos.map((todo) => { + if (todo.id === updatedTodo.id) { + todo = updatedTodo; + } + return todo; + }); + } +} diff --git a/package.json b/package.json index 89f6215f7..1363cd133 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@rx-angular/state": "^17.0.0", "@rx-angular/template": "^17.0.0", "@swc/helpers": "0.5.3", - "@tanstack/angular-query-experimental": "^5.12.1", + "@tanstack/angular-query-experimental": "^5.28.9", "rxjs": "7.8.1", "tailwindcss": "^3.3.5", "tslib": "^2.3.0",