From cc051bcb341e3a86c40a1388612bc37178593c89 Mon Sep 17 00:00:00 2001 From: Jan-Marlon Leibl Date: Wed, 22 Jan 2025 09:02:55 +0100 Subject: [PATCH] refactor: improve qualifications component structure and error handling --- .../qualification/table/table.component.html | 213 +++++----- .../qualification/table/table.component.ts | 363 ++++++++++++------ 2 files changed, 353 insertions(+), 223 deletions(-) diff --git a/src/app/qualification/table/table.component.html b/src/app/qualification/table/table.component.html index f45ab0a..93f7469 100644 --- a/src/app/qualification/table/table.component.html +++ b/src/app/qualification/table/table.component.html @@ -1,118 +1,127 @@ -
+
@defer { - @if (qualifications$ | async; as qualifications) { -
-
-
-

Qualifications

- - search - -
- -
-
+ @if (error$ | async; as error) { +
+
+ error +
+

{{ error }}

+

Please try refreshing the page.

-
- - @if (qualifications) { -
- - - - - - - - - - - - - -
Skill -
-
- - {{ qualification.skill[0]?.toUpperCase() }} - -
- -
-
Actions -
- - -
-
-
- } @else { - - - school -

No qualifications found

-
-
- }
+ } @else { + @if (qualifications$ | async; as qualifications) { +
+
+
+

Qualifications

+ + search + +
+ @if (isSearching$ | async) { + + + } +
+
+
+ +
+ + @if (qualifications.length) { +
+ + + + + + + + + + + + + +
Skill +
+
+ + {{ qualification.skill[0]?.toUpperCase() }} + +
+
+ +
+
+
Actions +
+ + +
+
+
+ } @else { + + + school +

No qualifications found

+
+
+ } +
+ } } } @placeholder { -
-
-
-
-
+
+
+
+
+
-
-
-
+
+
+
@for (i of [1, 2, 3]; track i) { -
+
}
- } @error { -
-
- error -
-

There was an error loading the qualifications.

-

Please try refreshing the page.

-
-
-
} @loading { -
- -
+ @if (isLoading$ | async) { +
+ +
+ } }
diff --git a/src/app/qualification/table/table.component.ts b/src/app/qualification/table/table.component.ts index bfba6e0..83cf553 100644 --- a/src/app/qualification/table/table.component.ts +++ b/src/app/qualification/table/table.component.ts @@ -1,26 +1,188 @@ -import {Component, inject, OnInit} from '@angular/core'; -import {CommonModule} from '@angular/common'; -import {catchError, debounceTime, distinctUntilChanged, filter, map, Observable, of, retry, Subject} from 'rxjs'; -import {HttpErrorResponse} from '@angular/common/http'; -import {Qualification} from "../Qualification"; -import {MatDialog} from "@angular/material/dialog"; -import QualificationService from "../../services/qualification.service"; -import {CreateComponent} from "../create/create.component"; -import {EditComponent} from "../edit/edit.component"; -import {DeleteComponent} from "../delete/delete.component"; -import {MatCardModule} from '@angular/material/card'; -import {MatButtonModule} from '@angular/material/button'; -import {MatIconModule} from '@angular/material/icon'; -import {MatProgressSpinnerModule} from '@angular/material/progress-spinner'; -import {MatSnackBar, MatSnackBarModule} from '@angular/material/snack-bar'; -import {MatDividerModule} from '@angular/material/divider'; -import {MatTooltipModule} from '@angular/material/tooltip'; -import {MatMenuModule} from '@angular/material/menu'; -import {MatTableModule} from '@angular/material/table'; -import {MatSortModule} from '@angular/material/sort'; -import {MatFormFieldModule} from "@angular/material/form-field"; -import {MatInputModule} from "@angular/material/input"; -import {DetailsComponent} from "../details/details.component"; +import { Component, inject, OnDestroy, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { + BehaviorSubject, + catchError, + combineLatest, + debounceTime, + distinctUntilChanged, + map, + Observable, + of, + retry, + Subject, + takeUntil, + startWith +} from 'rxjs'; +import { HttpErrorResponse } from '@angular/common/http'; +import { ComponentType } from '@angular/cdk/portal'; + +import { Qualification } from '../Qualification'; +import QualificationService from '../../services/qualification.service'; +import { CreateComponent } from '../create/create.component'; +import { EditComponent } from '../edit/edit.component'; +import { DeleteComponent } from '../delete/delete.component'; +import { DetailsComponent } from '../details/details.component'; + +import { MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { MatSnackBar, MatSnackBarConfig, MatSnackBarModule } from '@angular/material/snack-bar'; +import { MatCardModule } from '@angular/material/card'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatTableModule } from '@angular/material/table'; +import { MatSortModule } from '@angular/material/sort'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; + +class SearchEngine { + static readonly DEBOUNCE_TIME = 300; + + constructor( + private readonly items: Qualification[], + private readonly searchTerm: string + ) {} + + execute(): Qualification[] { + if (!this.searchTerm?.trim()) { + return this.items; + } + const normalizedTerm = this.searchTerm.toLowerCase().trim(); + return this.items.filter(item => + item.skill?.toLowerCase().includes(normalizedTerm) + ); + } +} + +class ErrorHandler { + private readonly config: MatSnackBarConfig = { + duration: 5000, + horizontalPosition: 'end', + verticalPosition: 'bottom', + panelClass: ['error-snackbar'] + }; + + constructor(private readonly snackBar: MatSnackBar) {} + + handleHttpError(error: HttpErrorResponse): Observable { + console.error('API Error:', error); + this.showError('Failed to load qualifications. Please try again.'); + return of([]); + } + + showError(message: string): void { + this.snackBar.open(message, 'Close', this.config); + } +} + +class DialogManager { + constructor( + private readonly dialog: MatDialog, + private readonly errorHandler: ErrorHandler + ) {} + + openDialog( + component: ComponentType, + data?: T + ): MatDialogRef { + return this.dialog.open(component, { data }); + } +} + +interface QualificationState { + items: Qualification[]; + filteredItems: Qualification[]; + searchTerm: string; + isLoading: boolean; + isSearching: boolean; + error: string | null; +} + +class QualificationStore { + private readonly initialState: QualificationState = { + items: [], + filteredItems: [], + searchTerm: '', + isLoading: false, + isSearching: false, + error: null + }; + + private readonly state = new BehaviorSubject(this.initialState); + private readonly searchTerm$ = new Subject(); + + readonly items$ = this.state.pipe(map(state => state.filteredItems)); + readonly isLoading$ = this.state.pipe(map(state => state.isLoading)); + readonly isSearching$ = this.state.pipe(map(state => state.isSearching)); + readonly error$ = this.state.pipe(map(state => state.error)); + + constructor( + private readonly api: QualificationService, + private readonly errorHandler: ErrorHandler + ) { + this.setupSearch(); + } + + private setupSearch(): void { + this.searchTerm$.pipe( + startWith(''), + debounceTime(SearchEngine.DEBOUNCE_TIME), + distinctUntilChanged() + ).subscribe(term => { + this.state.next({ + ...this.state.value, + isSearching: true, + searchTerm: term + }); + + const currentState = this.state.value; + const engine = new SearchEngine(currentState.items, term); + const filteredItems = engine.execute(); + + setTimeout(() => { + this.state.next({ + ...currentState, + searchTerm: term, + filteredItems, + isSearching: false + }); + }, 150); + }); + } + + load(): void { + this.setLoading(true); + this.api.getAll().pipe( + retry(3), + catchError(error => this.errorHandler.handleHttpError(error)) + ).subscribe(items => { + const engine = new SearchEngine(items, this.state.value.searchTerm); + this.state.next({ + ...this.state.value, + items, + filteredItems: engine.execute(), + isLoading: false, + error: null + }); + }); + } + + search(term: string): void { + this.searchTerm$.next(term); + } + + private setLoading(isLoading: boolean): void { + this.state.next({ ...this.state.value, isLoading }); + } + + complete(): void { + this.state.complete(); + this.searchTerm$.complete(); + } +} @Component({ selector: 'app-qualifications', @@ -38,114 +200,73 @@ import {DetailsComponent} from "../details/details.component"; MatTableModule, MatSortModule, MatFormFieldModule, - MatInputModule, + MatInputModule ], templateUrl: './table.component.html', styleUrl: './table.component.css' }) -export class QualificationsComponent implements OnInit { - private readonly qualificationService: QualificationService = inject(QualificationService); - private readonly snackBar: MatSnackBar = inject(MatSnackBar); - private readonly dialog: MatDialog = inject(MatDialog); +export class QualificationsComponent implements OnInit, OnDestroy { + private readonly errorHandler = new ErrorHandler(inject(MatSnackBar)); + private readonly dialogManager = new DialogManager( + inject(MatDialog), + this.errorHandler + ); + private readonly store = new QualificationStore( + inject(QualificationService), + this.errorHandler + ); - private static readonly MAX_RETRIES = 3; - - private allQualifications: Qualification[] = []; - private searchSubject = new Subject(); - public qualifications$: Observable = of([]); - public isSearching = false; - public readonly displayedColumns: string[] = ['skill', 'actions']; + private readonly destroy$ = new Subject(); - ngOnInit() { + protected readonly qualifications$ = this.store.items$; + protected readonly isLoading$ = this.store.isLoading$; + protected readonly isSearching$ = this.store.isSearching$; + protected readonly error$ = this.store.error$; + protected readonly displayedColumns = ['skill', 'actions'] as const; + + ngOnInit(): void { this.loadQualifications(); - this.setupSearch(); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + this.store.complete(); + } + + protected onSearch(event: Event): void { + const searchInput = event.target as HTMLInputElement; + this.store.search(searchInput.value); + } + + protected createQualification(): void { + this.openModalAndReload(CreateComponent); + } + + protected editQualification(qualification: Qualification): void { + this.openModalAndReload(EditComponent, qualification); + } + + protected deleteQualification(id: number): void { + this.openModalAndReload(DeleteComponent, id); + } + + protected viewQualificationDetails(qualification: Qualification): void { + this.dialogManager.openDialog(DetailsComponent, qualification); } private loadQualifications(): void { - this.fetchQualifications().subscribe(qualifications => { - this.allQualifications = qualifications; - this.qualifications$ = of(qualifications); - }); + this.store.load(); } - private setupSearch(): void { - this.searchSubject.pipe( - debounceTime(300), - distinctUntilChanged() - ).subscribe(searchTerm => { - this.isSearching = true; - setTimeout(() => { - const filteredQualifications = this.allQualifications.filter(qualification => - qualification.skill?.toLowerCase().includes(searchTerm) - ); - this.qualifications$ = of(filteredQualifications); - this.isSearching = false; - }, 150); - }); + private openModalAndReload(component: ComponentType, data?: T): void { + const dialogRef = this.dialogManager.openDialog(component, data); + dialogRef.afterClosed() + .pipe(takeUntil(this.destroy$)) + .subscribe(success => { + if (success === true) { + this.loadQualifications(); + } + }); } - - private fetchQualifications(): Observable { - return this.qualificationService.getAll().pipe( - retry(QualificationsComponent.MAX_RETRIES), - catchError((error: HttpErrorResponse) => { - console.error('Error fetching qualifications:', error); - this.showErrorMessage('Failed to load qualifications. Please try again.'); - return of([]); - }) - ); - } - - private showErrorMessage(message: string): void { - this.snackBar.open(message, 'Close', { - duration: 5000, - horizontalPosition: 'end', - verticalPosition: 'bottom', - panelClass: ['!bg-red-50', '!text-red-900', '!border', '!border-red-100'] - }); - } - - protected filterQualifications(event: Event): void { - const searchTerm = (event.target as HTMLInputElement).value.toLowerCase(); - this.searchSubject.next(searchTerm); - } - - openCreateModal() { - const dialogRef = this.dialog.open(CreateComponent); - - dialogRef.afterClosed().subscribe((success: boolean) => { - if (success) { - this.loadQualifications(); - } - }); - } - - openEditModal(qualification: Qualification) { - const dialogRef = this.dialog.open(EditComponent, { - data: qualification - }); - - dialogRef.afterClosed().subscribe((success: boolean) => { - if (success) { - this.loadQualifications(); - } - }); - } - - openDeleteModal(id: number) { - const dialogRef = this.dialog.open(DeleteComponent, { - data: id - }); - - dialogRef.afterClosed().subscribe((success: boolean) => { - if (success) { - this.loadQualifications(); - } - }); - } - - openDetailsModal(qualification: Qualification) { - this.dialog.open(DetailsComponent, { - data: qualification - }); - } -} +} \ No newline at end of file -- 2.47.2