diff --git a/apps/angular/5-crud-application/src/app/_interfaces/todo.interface.ts b/apps/angular/5-crud-application/src/app/_interfaces/todo.interface.ts new file mode 100644 index 000000000..db525ce1d --- /dev/null +++ b/apps/angular/5-crud-application/src/app/_interfaces/todo.interface.ts @@ -0,0 +1,6 @@ +export interface Todo { + userId: number; + id: number; + title: string; + completed: boolean; +} diff --git a/apps/angular/5-crud-application/src/app/_services/todos.service.ts b/apps/angular/5-crud-application/src/app/_services/todos.service.ts new file mode 100644 index 000000000..2e6467d54 --- /dev/null +++ b/apps/angular/5-crud-application/src/app/_services/todos.service.ts @@ -0,0 +1,33 @@ +import { HttpClient } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; +import { catchError, Observable } from 'rxjs'; +import { environment } from '../../environments/environment'; +import { Todo } from '../_interfaces/todo.interface'; +import { handleError } from '../_utils'; + +@Injectable({ providedIn: 'root' }) +export class TodosService { + private readonly http = inject(HttpClient); + private readonly url = environment.config.API_URL; + private readonly headers = environment.config.API_HEADERS; + + getTodos(): Observable { + return this.http + .get(this.url, { headers: this.headers }) + .pipe(catchError(handleError)); + } + + updateTodo(id: number, changes: Partial): Observable { + return this.http + .put(`${this.url}/${id}`, JSON.stringify(changes), { + headers: this.headers, + }) + .pipe(catchError(handleError)); + } + + deleteTodo(id: number): Observable { + return this.http + .delete(`${this.url}/${id}`) + .pipe(catchError(handleError)); + } +} diff --git a/apps/angular/5-crud-application/src/app/_store/actions/index.ts b/apps/angular/5-crud-application/src/app/_store/actions/index.ts new file mode 100644 index 000000000..005a943e5 --- /dev/null +++ b/apps/angular/5-crud-application/src/app/_store/actions/index.ts @@ -0,0 +1 @@ +export * from './todos.actions'; diff --git a/apps/angular/5-crud-application/src/app/_store/actions/todos.actions.spec.ts b/apps/angular/5-crud-application/src/app/_store/actions/todos.actions.spec.ts new file mode 100644 index 000000000..de78efcbc --- /dev/null +++ b/apps/angular/5-crud-application/src/app/_store/actions/todos.actions.spec.ts @@ -0,0 +1,102 @@ +import { Todo } from '../../_interfaces/todo.interface'; +import { + deleteTodoActions, + loadTodosActions, + updateTodoActions, +} from '../actions'; +describe('Todos Actions', () => { + describe('loadTodosActions', () => { + it('should create a load action', () => { + const action = loadTodosActions.load(); + expect(action).toEqual({ type: '[Todos/Load] Load' }); + }); + + it('should create a success action', () => { + const todos: Todo[] = [ + { userId: 1, id: 1, title: 'Test Todo', completed: false }, + ]; + const action = loadTodosActions.success({ todos }); + expect(action).toEqual({ + type: '[Todos/Load] Success', + todos: [{ userId: 1, id: 1, title: 'Test Todo', completed: false }], + }); + }); + + it('should create a failure action', () => { + const error = 'Error loading todos'; + const action = loadTodosActions.failure({ error }); + expect(action).toEqual({ + type: '[Todos/Load] Failure', + error: 'Error loading todos', + }); + }); + }); + + describe('updateTodoActions', () => { + it('should create an update action', () => { + const update = { id: 1, changes: { title: 'Updated Todo' } }; + const action = updateTodoActions.update({ update }); + expect(action).toEqual({ + type: '[Todos/Update] Update', + update: { id: 1, changes: { title: 'Updated Todo' } }, + }); + }); + + it('should create a success action', () => { + const update = { id: 1, changes: { title: 'Updated Todo' } }; + const action = updateTodoActions.success({ update }); + expect(action).toEqual({ + type: '[Todos/Update] Success', + update: { id: 1, changes: { title: 'Updated Todo' } }, + }); + }); + + it('should create a failure action', () => { + const error = 'Error updating todo'; + const action = updateTodoActions.failure({ error }); + expect(action).toEqual({ + type: '[Todos/Update] Failure', + error: 'Error updating todo', + }); + }); + }); + + describe('deleteTodoActions', () => { + it('should create a delete action', () => { + const todo: Todo = { + userId: 1, + id: 1, + title: 'Test Todo', + completed: false, + }; + const action = deleteTodoActions.delete({ todo }); + expect(action).toEqual({ + type: '[Todos/Delete] Delete', + todo: { userId: 1, id: 1, title: 'Test Todo', completed: false }, + }); + }); + + it('should create a success action', () => { + const todo: Todo = { + userId: 1, + id: 1, + title: 'Test Todo', + completed: false, + }; + const action = deleteTodoActions.success({ todo }); + expect(action).toEqual({ + type: '[Todos/Delete] Success', + todo: { userId: 1, id: 1, title: 'Test Todo', completed: false }, + }); + }); + + it('should create a failure action', () => { + const error = 'Error deleting todo'; + const action = deleteTodoActions.failure({ error }); + expect(action).toEqual({ + type: '[Todos/Delete] Failure', + error: 'Error deleting todo', + }); + }); + }); +}); diff --git a/apps/angular/5-crud-application/src/app/_store/actions/todos.actions.ts b/apps/angular/5-crud-application/src/app/_store/actions/todos.actions.ts new file mode 100644 index 000000000..1192b2400 --- /dev/null +++ b/apps/angular/5-crud-application/src/app/_store/actions/todos.actions.ts @@ -0,0 +1,31 @@ +import { Update } from '@ngrx/entity'; +import { createActionGroup, emptyProps, props } from '@ngrx/store'; + +import { Todo } from '../../_interfaces/todo.interface'; + +export const loadTodosActions = createActionGroup({ + source: 'Todos/Load', + events: { + Load: emptyProps(), + Success: props<{ todos: Todo[] }>(), + Failure: props<{ error: string }>(), + }, +}); + +export const updateTodoActions = createActionGroup({ + source: 'Todos/Update', + events: { + Update: props<{ update: Update }>(), + Success: props<{ update: Update }>(), + Failure: props<{ error: string }>(), + }, +}); + +export const deleteTodoActions = createActionGroup({ + source: 'Todos/Delete', + events: { + Delete: props<{ todo: Todo }>(), + Success: props<{ todo: Todo }>(), + Failure: props<{ error: string }>(), + }, +}); diff --git a/apps/angular/5-crud-application/src/app/_store/effects/index.ts b/apps/angular/5-crud-application/src/app/_store/effects/index.ts new file mode 100644 index 000000000..44992bfb1 --- /dev/null +++ b/apps/angular/5-crud-application/src/app/_store/effects/index.ts @@ -0,0 +1 @@ +export * from './todos.effects'; diff --git a/apps/angular/5-crud-application/src/app/_store/effects/todos.effects.ts b/apps/angular/5-crud-application/src/app/_store/effects/todos.effects.ts new file mode 100644 index 000000000..e0f17040a --- /dev/null +++ b/apps/angular/5-crud-application/src/app/_store/effects/todos.effects.ts @@ -0,0 +1,78 @@ +import { inject } from '@angular/core'; +import { Actions, createEffect, ofType } from '@ngrx/effects'; +import { catchError, map, mergeMap, of, switchMap } from 'rxjs'; +import { Todo } from '../../_interfaces/todo.interface'; +import { TodosService } from '../../_services/todos.service'; +import { + deleteTodoActions, + loadTodosActions, + updateTodoActions, +} from '../actions'; + +export const loadTodosEffect = createEffect( + ( + actions$: Actions = inject(Actions), + todosService = inject(TodosService), + ) => { + return actions$.pipe( + ofType(loadTodosActions.load), + switchMap(() => + todosService.getTodos().pipe( + map((todos) => loadTodosActions.success({ todos })), + catchError((error) => of(loadTodosActions.failure({ error }))), + ), + ), + ); + }, + { functional: true }, +); + +export const updateTodoEffect = createEffect( + ( + actions$: Actions = inject(Actions), + todosService = inject(TodosService), + ) => { + return actions$.pipe( + ofType(updateTodoActions.update), + mergeMap(({ update }) => + todosService + .updateTodo( + typeof update.id === 'string' ? parseInt(update.id, 10) : update.id, + update.changes, + ) + .pipe( + map((updatedTodo: Todo) => + updateTodoActions.success({ + update: { + id: updatedTodo.id, + changes: updatedTodo, + }, + }), + ), + catchError((error) => of(updateTodoActions.failure({ error }))), + ), + ), + ); + }, + { functional: true }, +); + +export const deleteTodoEffect = createEffect( + ( + actions$: Actions = inject(Actions), + todosService = inject(TodosService), + ) => { + return actions$.pipe( + ofType(deleteTodoActions.delete), + mergeMap(({ todo }) => + todosService.deleteTodo(todo.id).pipe( + map((deletedTodo: Todo) => + deleteTodoActions.success({ todo: deletedTodo }), + ), + catchError((error) => of(updateTodoActions.failure({ error }))), + ), + ), + ); + }, + { functional: true }, +); diff --git a/apps/angular/5-crud-application/src/app/_store/index.ts b/apps/angular/5-crud-application/src/app/_store/index.ts new file mode 100644 index 000000000..2c3fa1e61 --- /dev/null +++ b/apps/angular/5-crud-application/src/app/_store/index.ts @@ -0,0 +1,3 @@ +export * from './actions'; +export * from './effects'; +export * from './reducers'; diff --git a/apps/angular/5-crud-application/src/app/_store/reducers/index.ts b/apps/angular/5-crud-application/src/app/_store/reducers/index.ts new file mode 100644 index 000000000..0f4b807bf --- /dev/null +++ b/apps/angular/5-crud-application/src/app/_store/reducers/index.ts @@ -0,0 +1 @@ +export * from './todos.reducers'; diff --git a/apps/angular/5-crud-application/src/app/_store/reducers/todos.reducers.ts b/apps/angular/5-crud-application/src/app/_store/reducers/todos.reducers.ts new file mode 100644 index 000000000..73e099b45 --- /dev/null +++ b/apps/angular/5-crud-application/src/app/_store/reducers/todos.reducers.ts @@ -0,0 +1,82 @@ +import { createEntityAdapter, EntityState } from '@ngrx/entity'; +import { createFeature, createReducer, on } from '@ngrx/store'; +import { Todo } from '../../_interfaces/todo.interface'; +import { + deleteTodoActions, + loadTodosActions, + updateTodoActions, +} from '../actions'; + +export interface TodoState extends EntityState { + loadingTodos: boolean; + loadingTodoIds: number[]; + error: string | null; +} + +export const todoAdapter = createEntityAdapter(); + +export const initialState: TodoState = todoAdapter.getInitialState({ + loadingTodos: false, + loadingTodoIds: [], + error: null, +}); + +export const todosFeature = createFeature({ + name: 'todos', + reducer: createReducer( + initialState, + + // common - failure actions + on( + loadTodosActions.failure, + updateTodoActions.failure, + deleteTodoActions.failure, + (state, { error }) => ({ + ...state, + loadingTodos: false, + loadingTodoIds: [], + error: error, + }), + ), + + // load actions + on(loadTodosActions.load, (state) => ({ + ...state, + loadingTodos: true, + })), + on(loadTodosActions.success, (state, { todos }) => + todoAdapter.setAll(todos, { + ...state, + loadingTodos: false, + }), + ), + + // update actions + on(updateTodoActions.update, (state, { update }) => ({ + ...state, + loadingTodoIds: [...state.loadingTodoIds, update.id as number], + })), + on(updateTodoActions.success, (state, { update }) => + todoAdapter.updateOne(update, { + ...state, + loadingTodoIds: state.loadingTodoIds.filter((id) => id !== update.id), + }), + ), + + // delete actions + on(deleteTodoActions.delete, (state, { todo }) => ({ + ...state, + loadingTodoIds: [...state.loadingTodoIds, todo.id], + })), + on(deleteTodoActions.success, (state, { todo }) => + todoAdapter.removeOne(todo.id, { + ...state, + loadingTodoIds: state.loadingTodoIds.filter((id) => id !== todo.id), + }), + ), + ), + + extraSelectors: ({ selectTodosState }) => ({ + ...todoAdapter.getSelectors(selectTodosState), + }), +}); diff --git a/apps/angular/5-crud-application/src/app/_utils/http-error-handler.ts b/apps/angular/5-crud-application/src/app/_utils/http-error-handler.ts new file mode 100644 index 000000000..9b4cead4a --- /dev/null +++ b/apps/angular/5-crud-application/src/app/_utils/http-error-handler.ts @@ -0,0 +1,13 @@ +import { HttpErrorResponse } from '@angular/common/http'; + +import { Observable, throwError } from 'rxjs'; + +export function handleError(err: HttpErrorResponse): Observable { + const errorMsg = + err.error instanceof ErrorEvent + ? `Client-side error: ${err.error.message}` + : `Server-side error (${err.status}): ${err.message}`; + + console.warn(errorMsg); + return throwError(() => new Error(errorMsg)); +} diff --git a/apps/angular/5-crud-application/src/app/_utils/index.ts b/apps/angular/5-crud-application/src/app/_utils/index.ts new file mode 100644 index 000000000..371e67076 --- /dev/null +++ b/apps/angular/5-crud-application/src/app/_utils/index.ts @@ -0,0 +1 @@ +export * from './http-error-handler'; diff --git a/apps/angular/5-crud-application/src/app/app.component.ts b/apps/angular/5-crud-application/src/app/app.component.ts index 9152ff5e4..a698076fc 100644 --- a/apps/angular/5-crud-application/src/app/app.component.ts +++ b/apps/angular/5-crud-application/src/app/app.component.ts @@ -1,50 +1,11 @@ -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 { TodosComponent } from './containers/todos.component'; @Component({ - imports: [CommonModule], + imports: [TodosComponent], selector: 'app-root', template: ` -
- {{ todo.title }} - -
+ `, - 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 {} 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..42af505d0 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,24 @@ import { provideHttpClient } from '@angular/common/http'; -import { ApplicationConfig } from '@angular/core'; +import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; +import { provideEffects } from '@ngrx/effects'; +import { provideState, provideStore } from '@ngrx/store'; +import { + deleteTodoEffect, + loadTodosEffect, + todosFeature, + updateTodoEffect, +} from './_store'; export const appConfig: ApplicationConfig = { - providers: [provideHttpClient()], + providers: [ + provideZoneChangeDetection({ eventCoalescing: true }), + provideStore(), + provideState(todosFeature), + provideEffects([ + { effect: loadTodosEffect }, + { effect: updateTodoEffect }, + { effect: deleteTodoEffect }, + ]), + provideHttpClient(), + ], }; diff --git a/apps/angular/5-crud-application/src/app/components/item.component.ts b/apps/angular/5-crud-application/src/app/components/item.component.ts new file mode 100644 index 000000000..e5f0be703 --- /dev/null +++ b/apps/angular/5-crud-application/src/app/components/item.component.ts @@ -0,0 +1,35 @@ +import { + ChangeDetectionStrategy, + Component, + Input, + output, +} from '@angular/core'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; + +@Component({ + selector: 'app-item', + imports: [MatProgressSpinnerModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + + + @if (loadingAction) { + + } + `, + styles: [ + ` + :host { + display: flex; + gap: 8px; + } + `, + ], +}) +export class ItemComponent { + @Input() loadingAction!: boolean | null; + + readonly update = output(); + readonly delete = output(); +} diff --git a/apps/angular/5-crud-application/src/app/components/loading-indicator.component.ts b/apps/angular/5-crud-application/src/app/components/loading-indicator.component.ts new file mode 100644 index 000000000..ceb9e986a --- /dev/null +++ b/apps/angular/5-crud-application/src/app/components/loading-indicator.component.ts @@ -0,0 +1,33 @@ +import { Component, Input } from '@angular/core'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; + +@Component({ + selector: 'app-global-loading-indicator', + imports: [MatProgressSpinnerModule], + template: ` + @if (loading) { +
+ +
+ } + `, + styles: [ + ` + .loading-overlay { + position: fixed; + top: 0; + left: 0; + z-index: 1000; + width: 100%; + height: 100%; + background-color: rgba(255, 255, 255, 0.8); + display: flex; + justify-content: center; + align-items: center; + } + `, + ], +}) +export class GlobalLoadingIndicatorComponent { + @Input() loading!: boolean | null; +} diff --git a/apps/angular/5-crud-application/src/app/containers/todos.component.ts b/apps/angular/5-crud-application/src/app/containers/todos.component.ts new file mode 100644 index 000000000..ea95add17 --- /dev/null +++ b/apps/angular/5-crud-application/src/app/containers/todos.component.ts @@ -0,0 +1,70 @@ +import { AsyncPipe } from '@angular/common'; +import { Component, inject, OnInit } from '@angular/core'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { randText } from '@ngneat/falso'; +import { Store } from '@ngrx/store'; +import { map, Observable } from 'rxjs'; +import { Todo } from '../_interfaces/todo.interface'; +import { + deleteTodoActions, + loadTodosActions, + todosFeature, + updateTodoActions, +} from '../_store'; +import { ItemComponent } from '../components/item.component'; +import { GlobalLoadingIndicatorComponent } from '../components/loading-indicator.component'; + +@Component({ + selector: 'app-todos', + imports: [ + AsyncPipe, + MatProgressSpinnerModule, + ItemComponent, + GlobalLoadingIndicatorComponent, + ], + template: ` + + @for (todo of todos$ | async; track todo.id) { + + {{ todo.title }} + + } + `, +}) +export class TodosComponent implements OnInit { + loadingTodoList$!: Observable; + todos$!: Observable; + + readonly store = inject(Store); + + ngOnInit() { + this.loadingTodoList$ = this.store.select(todosFeature.selectLoadingTodos); + + this.store.dispatch(loadTodosActions.load()); + this.todos$ = this.store.select(todosFeature.selectAll); + } + + isActionInCurse(id: number): Observable { + return this.store.select((state) => + todosFeature.selectLoadingTodoIds(state).includes(id), + ); + } + + updateTodo(todo: Todo) { + this.store.dispatch( + updateTodoActions.update({ + update: { id: todo.id, changes: { title: randText() } }, + }), + ); + } + + deleteTodo(todo: Todo) { + this.store.dispatch(deleteTodoActions.delete({ todo })); + this.todos$ = this.todos$.pipe( + map((todos) => todos.filter((t) => t.id !== todo.id)), + ); + } +} diff --git a/apps/angular/5-crud-application/src/environments/environment.ts b/apps/angular/5-crud-application/src/environments/environment.ts new file mode 100644 index 000000000..759ca700c --- /dev/null +++ b/apps/angular/5-crud-application/src/environments/environment.ts @@ -0,0 +1,9 @@ +export const environment = { + production: false, + config: { + API_URL: 'https://jsonplaceholder.typicode.com/todos', + API_HEADERS: { + 'Content-type': 'application/json; charset=UTF-8', + }, + }, +};