New Angular based web version #1

Closed
thomas.nuyken wants to merge 150 commits from main into ddd
18 changed files with 612 additions and 4 deletions
Showing only changes of commit 9c372b31a6 - Show all commits

View File

@@ -1,9 +1,17 @@
<main class="main">
<header class="h-12 bg-primary text-primary-contrast">
<div class="header max-content-width mx-auto">
<div class="header max-content-width mx-auto flex items-center justify-between">
<a routerLink="/" class="reset cursor-pointer">
Vegasco
</a>
<span class="flex items-center gap-4">
<a routerLink="/entries" class="reset cursor-pointer">
Einträge
</a>
<a routerLink="/cars" class="reset cursor-pointer">
Autos
</a>
</span>
</div>
</header>
<div class="content max-content-width mx-auto">

View File

@@ -9,5 +9,9 @@ export const routes: Routes = [
{
path: 'entries',
loadChildren: () => import('./modules/entries/entries.routes').then(m => m.routes)
},
{
path: 'cars',
loadChildren: () => import('./modules/cars/cars.routes').then(m => m.routes)
}
];

View File

@@ -1,12 +1,12 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { RouterLink, RouterOutlet } from '@angular/router';
import { MessageService } from 'primeng/api';
import { ToastModule } from 'primeng/toast';
@Component({
selector: 'app-root',
imports: [RouterOutlet, ToastModule],
imports: [RouterLink, RouterOutlet, ToastModule],
providers: [MessageService],
templateUrl: './app.html',
styleUrl: './app.scss'

View File

@@ -0,0 +1,17 @@
import { Routes } from "@angular/router";
import { CarsComponent } from "./cars/cars.component";
export const routes: Routes = [
{
path: '',
component: CarsComponent
},
{
path: 'create',
loadComponent: () => import('./edit-car/edit-car.component').then(m => m.EditCarComponent)
},
{
path: 'edit/:id',
loadComponent: () => import('./edit-car/edit-car.component').then(m => m.EditCarComponent)
}
];

View File

@@ -0,0 +1,38 @@
<section>
<p-scrollTop />
<div class="mb-4 flex gap-2 md:justify-end">
<div>
<p-button label="Erstellen" routerLink="/cars/create">
<ng-icon name="matAddSharp"></ng-icon>
</p-button>
</div>
</div>
<div>
@if (nonDeletedCars$ | async; as cars) {
<p-dataView
[value]="cars"
[paginator]="true"
[rows]="25"
[rowsPerPageOptions]="[10, 25, 50, 100]"
[pageLinks]="0"
[showCurrentPageReport]="true"
currentPageReportTemplate="{currentPage} / {totalPages}"
layout="list">
<ng-template #list let-cars>
<div class="flex flex-col gap-2">
@for (car of cars; track car.id) {
<app-car-card [car]="car"
(carDeleted)="onCarDeleted($event)" />
}
</div>
</ng-template>
</p-dataView>
} @else {
<div class="flex flex-col gap-2">
@for (_ of skeletonsIterationSource; track $index) {
<p-skeleton height="4rem" styleClass="mb-2" />
}
</div>
}
</div>
</section>

View File

@@ -0,0 +1,3 @@
th, td {
padding: 0.5rem;
}

View File

@@ -0,0 +1,117 @@
import { AsyncPipe, CommonModule } from '@angular/common';
import { HttpErrorResponse } from '@angular/common/http';
import { Component, DestroyRef, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import {
matAddSharp,
} from '@ng-icons/material-icons/sharp';
import { CarClient } from '@vegasco-web/api/cars/car-client';
import { MessageService } from 'primeng/api';
import { ButtonModule } from 'primeng/button';
import { DataViewModule } from 'primeng/dataview';
import { ScrollTopModule } from 'primeng/scrolltop';
import { SelectModule } from 'primeng/select';
import { SkeletonModule } from 'primeng/skeleton';
import {
BehaviorSubject,
catchError,
combineLatest,
EMPTY,
map,
Observable,
throwError
} from 'rxjs';
import { CarCardComponent } from './components/car-card/car-card.component';
@Component({
selector: 'app-entries',
imports: [
AsyncPipe,
ButtonModule,
CommonModule,
DataViewModule,
CarCardComponent,
NgIconComponent,
ReactiveFormsModule,
RouterLink,
ScrollTopModule,
SelectModule,
SkeletonModule,
],
providers: [
provideIcons({
matAddSharp,
}),
],
templateUrl: './cars.component.html',
styleUrl: './cars.component.scss'
})
export class CarsComponent {
private readonly carClient = inject(CarClient);
private readonly messageService = inject(MessageService);
protected readonly nonDeletedCars$: Observable<Car[]>;
protected readonly skeletonsIterationSource = Array(10).fill(0);
private readonly deletedCars$ = new BehaviorSubject(<string[]>[]);
constructor() {
const cars$ = this.carClient.getAll()
.pipe(
map(response => response.cars),
map((cars) => cars
.sort((a, b) => a.name.localeCompare(b.name))),
);
this.nonDeletedCars$ = combineLatest([
cars$,
this.deletedCars$
])
.pipe(
takeUntilDestroyed(),
map(([cars, deletedCars]) => cars.filter(car => !deletedCars.includes(car.id))),
catchError((error) => this.handleGetCarsError(error)),
);
}
onCarDeleted(entry: Car): void {
this.deletedCars$.next([...this.deletedCars$.value, entry.id]);
this.messageService.add({
severity: 'success',
summary: 'Auto gelöscht',
detail: 'Das Auto wurde erfolgreich gelöscht.',
});
}
private handleGetCarsError(error: unknown): Observable<never> {
if (!(error instanceof HttpErrorResponse)) {
return throwError(() => new Error('An unexpected error occurred'));
}
switch (true) {
case error.status >= 500 && error.status <= 599:
this.messageService.add({
severity: 'error',
summary: 'Serverfehler',
detail:
'Beim Abrufen der Einträge ist ein Fehler aufgetreten. Bitte versuche es erneut.',
});
break;
default:
console.error(error);
this.messageService.add({
severity: 'error',
summary: 'Unerwarteter Fehler',
detail:
'Beim Abrufen der Einträge hat der Server eine unerwartete Antwort zurückgegeben.',
});
break;
}
return EMPTY;
}
}

View File

@@ -0,0 +1,22 @@
<p-confirmDialog></p-confirmDialog>
<div class="flex rounded-border shadow">
<div class="grow p-4 pos-relative edit-button" (click)="navigateToEdit()" role="button"
aria-roledescription="Bearbeite diesen Eintrag">
<div class="grid grid-cols-4 gap-4">
<div class="col-span-4 sm:col-span-2 md:col-span-1 flex my-auto items-center justify-center">
<div class="flex gap-2 items-center">
<ng-icon name="matDirectionsCarOutline" />
<div>{{ car().name }}</div>
</div>
</div>
</div>
</div>
<div class="bg-red-500 text-white rounded-r text-center flex flex-col justify-center">
<button type="button" title="Löschen" class="reset cursor-pointer primary-color-text p-4 h-full rounded-r"
(click)="confirmDeleteCar()">
<ng-icon name="matDeleteSharp"></ng-icon>
</button>
</div>
</div>

View File

@@ -0,0 +1,3 @@
.edit-button {
cursor: pointer;
}

View File

@@ -0,0 +1,116 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Component, DestroyRef, inject, input, output } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import {
matDirectionsCarOutline,
} from '@ng-icons/material-icons/outline';
import {
matDeleteSharp
} from '@ng-icons/material-icons/sharp';
import { CarClient } from '@vegasco-web/api/cars/car-client';
import { RoutingService } from '@vegasco-web/services/routing.service';
import { ConfirmationService, MessageService } from 'primeng/api';
import { ButtonModule } from 'primeng/button';
import { CardModule } from 'primeng/card';
import { ConfirmDialogModule } from 'primeng/confirmdialog';
import { catchError, EMPTY, Observable, tap, throwError } from 'rxjs';
@Component({
selector: 'app-car-card',
imports: [
ButtonModule,
CardModule,
ConfirmDialogModule,
NgIconComponent,
],
providers: [
provideIcons({
matDeleteSharp,
matDirectionsCarOutline,
}),
ConfirmationService,
],
templateUrl: './car-card.component.html',
styleUrl: './car-card.component.scss'
})
export class CarCardComponent {
readonly car = input.required<Car>();
readonly carDeleted = output<Car>();
private readonly routingService = inject(RoutingService);
private readonly carClient = inject(CarClient);
private readonly messageService = inject(MessageService);
private readonly confirmationService = inject(ConfirmationService);
private readonly destroyRef = inject(DestroyRef);
async navigateToEdit(): Promise<void> {
await this.routingService.navigateToEditCar(this.car().id);
}
confirmDeleteCar(): void {
this.confirmationService.confirm({
closeOnEscape: true,
dismissableMask: true,
header: 'Bist du sicher?',
message: `Möchtest du das Auto "${this.car().name}" wirklich löschen?`,
acceptButtonProps: {
label: 'Löschen',
severity: 'danger',
},
rejectButtonProps: {
label: 'Abbrechen',
outlined: true,
},
accept: () => this.deleteCar(),
});
}
deleteCar(): void {
this.carClient.delete(this.car().id)
.pipe(
takeUntilDestroyed(this.destroyRef),
tap(() => this.carDeleted.emit(this.car())),
catchError((error) => this.handleError(error)),
)
.subscribe();
}
private handleError(error: unknown): Observable<never> {
if (!(error instanceof HttpErrorResponse)) {
return throwError(() => error);
}
switch (true) {
case error.status >= 500 && error.status <= 599:
this.messageService.add({
severity: 'error',
summary: 'Serverfehler',
detail:
'Beim Löschen des Autos ist ein Fehler aufgetreten. Bitte versuche es erneut.',
});
break;
case error.status === 400:
this.messageService.add({
severity: 'error',
summary: 'Clientfehler',
detail:
'Die Anwendung scheint falsche Daten an den Server zu senden.',
});
break;
default:
console.error(error);
this.messageService.add({
severity: 'error',
summary: 'Unerwarteter Fehler',
detail:
'Beim Löschen des Autos hat der Server eine unerwartete Antwort zurückgegeben.',
});
break;
}
return EMPTY;
}
}

View File

@@ -0,0 +1 @@
<span class="required">*</span>

View File

@@ -0,0 +1,3 @@
.required {
color: red;
}

View File

@@ -0,0 +1,11 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-required-marker',
imports: [
],
templateUrl: './required-marker.component.html',
styleUrl: './required-marker.component.scss'
})
export class RequiredMarkerComponent {
}

View File

@@ -0,0 +1,31 @@
@if (!isCarDataLoaded()) {
<div class="flex flex-col gap-6">
<p-skeleton height="3.5rem" />
<div class="flex flex-row gap-4">
<p-skeleton height="3.5rem" width="10rem" />
<p-skeleton height="3.5rem" width="10rem" />
</div>
</div>
} @else {
<form [formGroup]="formGroup" class="flex flex-col gap-4" (ngSubmit)="onSubmit()">
<div class="flex flex-col gap-2">
<label [for]="formFieldNames.name">
Name
<app-required-marker />
</label>
<input
id="name"
placeholder="Name eingeben"
type="text"
pInputText
[formControlName]="formFieldNames.name" />
</div>
<div class="flex gap-2">
<p-button type="button" label="Abbrechen" (click)="navigateToOverviewPage()" severity="warn" />
<p-button type="submit" label="Abschicken" severity="success" [disabled]="formGroup.invalid" />
</div>
</form>
}

View File

@@ -0,0 +1,217 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Component, DestroyRef, inject, input, OnInit, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { CarClient } from '@vegasco-web/api/cars/car-client';
import { RoutingService } from '@vegasco-web/services/routing.service';
import { MessageService } from 'primeng/api';
import { ButtonModule } from 'primeng/button';
import { ChipModule } from 'primeng/chip';
import { DatePickerModule } from 'primeng/datepicker';
import { FloatLabelModule } from 'primeng/floatlabel';
import { InputGroupModule } from 'primeng/inputgroup';
import { InputGroupAddonModule } from 'primeng/inputgroupaddon';
import { InputNumberModule } from 'primeng/inputnumber';
import { InputTextModule } from 'primeng/inputtext';
import { MultiSelectModule } from 'primeng/multiselect';
import { SelectModule } from 'primeng/select';
import { SkeletonModule } from 'primeng/skeleton';
import { catchError, EMPTY, Observable, switchMap, tap, throwError } from 'rxjs';
import { RequiredMarkerComponent } from './components/required-marker.component';
@Component({
selector: 'app-edit-entry',
imports: [
ButtonModule,
ChipModule,
DatePickerModule,
FloatLabelModule,
InputGroupAddonModule,
InputGroupModule,
InputNumberModule,
InputTextModule,
MultiSelectModule,
ReactiveFormsModule,
RequiredMarkerComponent,
SelectModule,
SkeletonModule,
],
templateUrl: './edit-car.component.html',
styleUrl: './edit-car.component.scss'
})
export class EditCarComponent implements OnInit {
private readonly carClient = inject(CarClient);
private readonly routingService = inject(RoutingService);
private readonly destroyRef = inject(DestroyRef);
private readonly messageService = inject(MessageService);
protected readonly id = input<string | undefined>(undefined);
protected readonly today = new Date();
protected readonly formFieldNames = {
name: 'name',
} as const;
protected readonly formGroup = new FormGroup({
[this.formFieldNames.name]: new FormControl<string | null>({ value: null, disabled: true }, [Validators.required]),
});
protected readonly isCarDataLoaded = signal(false);
ngOnInit(): void {
this.loadEntryDetailsAndEnableControls();
}
private loadEntryDetailsAndEnableControls() {
const carId = this.id();
if (carId === undefined || carId === null) {
this.enableFormControls();
this.isCarDataLoaded.set(true);
return;
}
this.carClient
.getSingle(carId)
.pipe(
takeUntilDestroyed(this.destroyRef),
catchError((error) => this.handleGetError(error)),
tap((car) => {
this.formGroup.patchValue({
[this.formFieldNames.name]: car.name,
});
}),
tap(() => {
this.enableFormControls();
this.isCarDataLoaded.set(true);
}),
)
.subscribe();
}
private enableFormControls(): void {
for (const controlName of Object.values(this.formFieldNames)) {
const control = this.formGroup.get(controlName);
if (control) {
control.enable();
} else {
console.warn(`Form control '${controlName}' not found.`);
}
}
}
async navigateToOverviewPage(): Promise<void> {
await this.routingService.navigateToCars();
}
onSubmit(): void {
if (this.formGroup.invalid) {
this.formGroup.markAllAsTouched();
return;
}
var carId = this.id();
if (carId === undefined || carId === null) {
this.createCar();
return;
}
this.updateCar(carId);
}
private getFormData() {
return {
name: this.formGroup.controls[this.formFieldNames.name].value!,
};
}
createCar() {
var request: CreateCarRequest = this.getFormData();
this.carClient.create(request)
.pipe(
takeUntilDestroyed(this.destroyRef),
catchError((error) => this.handleCreateOrUpdateError(error, false)),
switchMap(() => this.routingService.navigateToCars())
)
.subscribe();
}
updateCar(id: string) {
var request: UpdateCarRequest = this.getFormData();
this.carClient.update(id, request)
.pipe(
takeUntilDestroyed(this.destroyRef),
catchError((error) => this.handleCreateOrUpdateError(error, true)),
switchMap(() => this.routingService.navigateToCars())
)
.subscribe();
}
private handleGetError(error: unknown): Observable<never> {
if (!(error instanceof HttpErrorResponse)) {
return throwError(() => error);
}
switch (true) {
case error.status >= 500 && error.status <= 599:
this.messageService.add({
severity: 'error',
summary: 'Serverfehler',
detail:
'Beim Abrufen des Autos ist ein Fehler aufgetreten. Bitte versuche es erneut.',
});
break;
default:
console.error(error);
this.messageService.add({
severity: 'error',
summary: 'Unerwarteter Fehler',
detail:
'Beim Abrufen des Autos hat der Server eine unerwartete Antwort zurückgegeben.',
});
break;
}
return EMPTY;
}
private handleCreateOrUpdateError(error: unknown, isUpdate: boolean): Observable<never> {
if (!(error instanceof HttpErrorResponse)) {
return throwError(() => error);
}
const action = isUpdate ? 'Aktualisieren' : 'Erstellen';
switch (true) {
case error.status >= 500 && error.status <= 599:
this.messageService.add({
severity: 'error',
summary: 'Serverfehler',
detail:
`Beim ${action} des Eintrags ist ein Fehler aufgetreten. Bitte versuche es erneut.`,
});
break;
case error.status === 400:
this.messageService.add({
severity: 'error',
summary: 'Clientfehler',
detail:
'Die Anwendung scheint falsche Daten an den Server zu senden.',
});
break;
default:
console.error(error);
this.messageService.add({
severity: 'error',
summary: 'Unerwarteter Fehler',
detail:
`Beim ${action} des Eintrags hat der Server eine unerwartete Antwort zurückgegeben.`,
});
break;
}
return EMPTY;
}
}

View File

@@ -169,6 +169,11 @@ export class EditEntryComponent implements OnInit {
}
onSubmit(): void {
if (this.formGroup.invalid) {
this.formGroup.markAllAsTouched();
return;
}
var entryId = this.id();
if (entryId === undefined || entryId === null) {
this.createEntry();

View File

@@ -18,4 +18,16 @@ export class RoutingService {
async navigateToCreateEntry(): Promise<void> {
await this.router.navigate(['entries', 'create']);
}
async navigateToCars(): Promise<void> {
await this.router.navigateByUrl('/cars');
}
async navigateToEditCar(entryId: string): Promise<void> {
await this.router.navigate(['cars', 'edit', entryId]);
}
async navigateToCreateCar(): Promise<void> {
await this.router.navigate(['cars', 'create']);
}
}