New Angular based web version #1

Closed
thomas.nuyken wants to merge 150 commits from main into ddd
10 changed files with 114 additions and 107 deletions
Showing only changes of commit cb3c8c0d18 - Show all commits

View File

@@ -1,14 +1,16 @@
import {inject, Injectable} from '@angular/core'; import { HttpClient } from '@angular/common/http';
import {HttpClient} from '@angular/common/http'; import { inject, Injectable } from '@angular/core';
import {API_BASE_PATH} from '../api-base-path'; import { GetConsumptionEntriesResponse } from '@vegasco-web/api/consumptions/get-consumption-entries-response';
import {map, Observable} from 'rxjs'; import { map, Observable } from 'rxjs';
import { API_BASE_PATH } from '../api-base-path';
import { ConsumptionEntry } from './consumption-entry';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class ConsumptionClient { export class ConsumptionClient {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
private readonly apiBasePath = inject(API_BASE_PATH, {optional: true}); private readonly apiBasePath = inject(API_BASE_PATH, { optional: true });
getAll(): Observable<GetConsumptionEntriesResponse> { getAll(): Observable<GetConsumptionEntriesResponse> {
return this.http.get<GetConsumptionEntriesResponse>(`${this.apiBasePath}/v1/consumptions`); return this.http.get<GetConsumptionEntriesResponse>(`${this.apiBasePath}/v1/consumptions`);

View File

@@ -1,4 +1,4 @@
interface ConsumptionEntry { export interface ConsumptionEntry {
id: string; id: string;
dateTime: string; dateTime: string;
distance: number; distance: number;

View File

@@ -0,0 +1,10 @@
export interface GetConsumptionEntriesEntry {
id: string;
dateTime: string;
distance: number;
amount: number;
car: {
id: string;
name: string;
};
}

View File

@@ -1,3 +1,5 @@
interface GetConsumptionEntriesResponse { import { GetConsumptionEntriesEntry } from "./get-consumption-entries-entry";
consumptions: ConsumptionEntry[];
} export interface GetConsumptionEntriesResponse {
consumptions: GetConsumptionEntriesEntry[];
}

View File

@@ -1,9 +0,0 @@
export interface Consumption {
id: string;
dateTime: string;
distance: number;
amount: number;
ignoreInCalculation: boolean;
carId: string;
car: Car;
}

View File

@@ -3,17 +3,17 @@ import { HttpErrorResponse } from '@angular/common/http';
import { Component, DestroyRef, inject, input, output } from '@angular/core'; import { Component, DestroyRef, inject, input, output } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NgIconComponent, provideIcons } from '@ng-icons/core'; import { NgIconComponent, provideIcons } from '@ng-icons/core';
import {
matCalendarMonthSharp,
matDeleteSharp,
matStraightenSharp,
matLocalGasStationSharp,
} from '@ng-icons/material-icons/sharp';
import { import {
matDirectionsCarOutline, matDirectionsCarOutline,
} from '@ng-icons/material-icons/outline'; } from '@ng-icons/material-icons/outline';
import {
matCalendarMonthSharp,
matDeleteSharp,
matLocalGasStationSharp,
matStraightenSharp,
} from '@ng-icons/material-icons/sharp';
import { ConsumptionClient } from '@vegasco-web/api/consumptions/consumption-client'; import { ConsumptionClient } from '@vegasco-web/api/consumptions/consumption-client';
import { Consumption } from '@vegasco-web/api/models/consumption'; import { GetConsumptionEntriesEntry } from '@vegasco-web/api/consumptions/get-consumption-entries-entry';
import { RoutingService } from '@vegasco-web/services/routing.service'; import { RoutingService } from '@vegasco-web/services/routing.service';
import { ConfirmationService, MessageService } from 'primeng/api'; import { ConfirmationService, MessageService } from 'primeng/api';
import { ButtonModule } from 'primeng/button'; import { ButtonModule } from 'primeng/button';
@@ -45,9 +45,9 @@ import { catchError, EMPTY, Observable, tap, throwError } from 'rxjs';
styleUrl: './entry-card.component.scss' styleUrl: './entry-card.component.scss'
}) })
export class EntryCardComponent { export class EntryCardComponent {
readonly entry = input.required<Consumption>(); readonly entry = input.required<GetConsumptionEntriesEntry>();
readonly entryDeleted = output<Consumption>(); readonly entryDeleted = output<GetConsumptionEntriesEntry>();
private readonly routingService = inject(RoutingService); private readonly routingService = inject(RoutingService);
private readonly consumptionClient = inject(ConsumptionClient); private readonly consumptionClient = inject(ConsumptionClient);

View File

@@ -1,8 +1,16 @@
import { AsyncPipe, CommonModule } from '@angular/common'; import { AsyncPipe, CommonModule } from '@angular/common';
import { HttpErrorResponse } from '@angular/common/http';
import { Component, DestroyRef, inject, OnInit } from '@angular/core'; import { Component, DestroyRef, inject, OnInit } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router'; 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 { ConsumptionClient } from '@vegasco-web/api/consumptions/consumption-client';
import { GetConsumptionEntriesEntry } from '@vegasco-web/api/consumptions/get-consumption-entries-entry';
import { MessageService } from 'primeng/api'; import { MessageService } from 'primeng/api';
import { ButtonModule } from 'primeng/button'; import { ButtonModule } from 'primeng/button';
import { DataViewModule } from 'primeng/dataview'; import { DataViewModule } from 'primeng/dataview';
@@ -20,14 +28,9 @@ import {
tap, tap,
throwError throwError
} from 'rxjs'; } from 'rxjs';
import { EntriesOverviewService } from './services/entries-overview.service';
import { EntryCardComponent } from './components/entry-card/entry-card.component';
import {
matAddSharp,
} from '@ng-icons/material-icons/sharp';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { HttpErrorResponse } from '@angular/common/http';
import { SelectedCarService } from '../services/selected-car.service'; import { SelectedCarService } from '../services/selected-car.service';
import { EntryCardComponent } from './components/entry-card/entry-card.component';
import { ConsumptionEntry } from '@vegasco-web/api/consumptions/consumption-entry';
@Component({ @Component({
selector: 'app-entries', selector: 'app-entries',
@@ -48,18 +51,18 @@ import { SelectedCarService } from '../services/selected-car.service';
provideIcons({ provideIcons({
matAddSharp, matAddSharp,
}), }),
EntriesOverviewService,
], ],
templateUrl: './entries.component.html', templateUrl: './entries.component.html',
styleUrl: './entries.component.scss' styleUrl: './entries.component.scss'
}) })
export class EntriesComponent implements OnInit { export class EntriesComponent implements OnInit {
private readonly entriesOverviewService = inject(EntriesOverviewService); private readonly carClient = inject(CarClient);
private readonly consumptionClient = inject(ConsumptionClient);
private readonly messageService = inject(MessageService); private readonly messageService = inject(MessageService);
private readonly selectedCarService = inject(SelectedCarService); private readonly selectedCarService = inject(SelectedCarService);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
protected readonly consumptionEntries$: Observable<ConsumptionEntry[]>; protected readonly consumptionEntries$: Observable<GetConsumptionEntriesEntry[]>;
protected readonly cars$: Observable<Car[]>; protected readonly cars$: Observable<Car[]>;
protected readonly skeletonsIterationSource = Array(10).fill(0); protected readonly skeletonsIterationSource = Array(10).fill(0);
@@ -69,9 +72,10 @@ export class EntriesComponent implements OnInit {
private readonly deletedEntries$ = new BehaviorSubject(<string[]>[]); private readonly deletedEntries$ = new BehaviorSubject(<string[]>[]);
constructor() { constructor() {
const entries = this.entriesOverviewService.getEntries() const entries = this.consumptionClient.getAll()
.pipe( .pipe(
takeUntilDestroyed(), takeUntilDestroyed(),
map(response => response.consumptions),
catchError((error) => this.handleGetEntriesError(error)) catchError((error) => this.handleGetEntriesError(error))
); );
@@ -92,13 +96,14 @@ export class EntriesComponent implements OnInit {
return nonDeletedEntries; return nonDeletedEntries;
} }
return nonDeletedEntries.filter(entry => entry.carId === selectedCar.id); return nonDeletedEntries.filter(entry => entry.car.id === selectedCar.id);
}) })
); );
this.cars$ = this.entriesOverviewService.getCars() this.cars$ = this.carClient.getAll()
.pipe( .pipe(
takeUntilDestroyed(), takeUntilDestroyed(),
map(response => response.cars),
map((cars) => cars map((cars) => cars
.sort((a, b) => a.name.localeCompare(b.name))), .sort((a, b) => a.name.localeCompare(b.name))),
tap((cars) => { tap((cars) => {
@@ -129,7 +134,7 @@ export class EntriesComponent implements OnInit {
.subscribe(); .subscribe();
} }
onEntryDeleted(entry: ConsumptionEntry): void { onEntryDeleted(entry: GetConsumptionEntriesEntry): void {
this.deletedEntries$.next([...this.deletedEntries$.value, entry.id]); this.deletedEntries$.next([...this.deletedEntries$.value, entry.id]);
this.messageService.add({ this.messageService.add({
severity: 'success', severity: 'success',

View File

@@ -1,50 +0,0 @@
import { inject, Injectable } from "@angular/core";
import { CarClient } from "@vegasco-web/api/cars/car-client";
import { ConsumptionClient } from "@vegasco-web/api/consumptions/consumption-client";
import { Consumption } from "@vegasco-web/api/models/consumption";
import { RoutingService } from "@vegasco-web/services/routing.service";
import { combineLatest, map, Observable, shareReplay } from "rxjs";
@Injectable()
export class EntriesOverviewService {
private readonly carClient = inject(CarClient);
private readonly consumptionClient = inject(ConsumptionClient);
private cachedCars$: Observable<Car[]> | null = null;
private ensureCarsAreCached(): void {
if (this.cachedCars$ !== null) {
return;
}
this.cachedCars$ = this.carClient.getAll()
.pipe(
map(response => response.cars),
shareReplay(1)
);
}
getEntries(): Observable<Consumption[]> {
this.ensureCarsAreCached();
const entries$ = this.consumptionClient.getAll()
.pipe(map(response => response.consumptions));
return combineLatest([this.cachedCars$!, entries$])
.pipe(
map(([cars, entries]) => {
return entries
.sort((a, b) => b.dateTime.localeCompare(a.dateTime))
.map((entry): Consumption => ({
...entry,
car: cars.find(car => car.id === entry.carId)!
}));
})
)
}
getCars(): Observable<Car[]> {
this.ensureCarsAreCached();
return this.cachedCars$!;
}
}

View File

@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Vegasco.Server.Api.Cars;
using Vegasco.Server.Api.Persistence; using Vegasco.Server.Api.Persistence;
namespace Vegasco.Server.Api.Consumptions; namespace Vegasco.Server.Api.Consumptions;
@@ -17,7 +18,18 @@ public static class GetConsumptions
DateTimeOffset DateTime, DateTimeOffset DateTime,
double Distance, double Distance,
double Amount, double Amount,
Guid CarId); CarDto Car,
double? LiterPer100Km);
public record CarDto(
Guid Id,
string Name)
{
public static CarDto FromCar(Car car)
{
return new CarDto(car.Id.Value, car.Name);
}
}
public class Request public class Request
{ {
@@ -39,16 +51,37 @@ public static class GetConsumptions
ApplicationDbContext dbContext, ApplicationDbContext dbContext,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
List<ResponseDto> consumptions = await dbContext.Consumptions List<Consumption> consumptions = await dbContext.Consumptions
.OrderByDescending(x => x.DateTime) .OrderByDescending(x => x.DateTime)
.Select(x => .Include(x => x.Car)
new ResponseDto(x.Id.Value, x.DateTime, x.Distance, x.Amount, x.CarId.Value))
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
ApiResponse apiResponse = new() List<ResponseDto> responses = [];
for (int i = 0; i < consumptions.Count; i++)
{ {
Consumptions = consumptions Consumption consumption = consumptions[i];
};
double? literPer100Km = null;
bool isLast = i == consumptions.Count - 1;
if (!isLast)
{
Consumption previousConsumption = consumptions[i + 1];
double distanceDiff = consumption.Distance - previousConsumption.Distance;
literPer100Km = consumption.Amount / (distanceDiff / 100);
}
responses.Add(new ResponseDto(
consumption.Id.Value,
consumption.DateTime,
consumption.Distance,
consumption.Amount,
CarDto.FromCar(consumption.Car),
literPer100Km));
}
ApiResponse apiResponse = new() { Consumptions = responses };
return TypedResults.Ok(apiResponse); return TypedResults.Ok(apiResponse);
} }
} }

View File

@@ -31,7 +31,7 @@ public class GetConsumptionsTests : IAsyncLifetime
// Arrange // Arrange
List<CreateConsumption.Response> createdConsumptions = []; List<CreateConsumption.Response> createdConsumptions = [];
const int numberOfConsumptions = 3; const int numberOfConsumptions = 3;
for (var i = 0; i < numberOfConsumptions; i++) for (int i = 0; i < numberOfConsumptions; i++)
{ {
CreateConsumption.Response createdConsumption = await CreateConsumptionAsync(); CreateConsumption.Response createdConsumption = await CreateConsumptionAsync();
createdConsumptions.Add(createdConsumption); createdConsumptions.Add(createdConsumption);
@@ -42,8 +42,16 @@ public class GetConsumptionsTests : IAsyncLifetime
// Assert // Assert
response.StatusCode.Should().Be(HttpStatusCode.OK); response.StatusCode.Should().Be(HttpStatusCode.OK);
var apiResponse = await response.Content.ReadFromJsonAsync<GetConsumptions.ApiResponse>(); GetConsumptions.ApiResponse? apiResponse =
apiResponse!.Consumptions.Should().BeEquivalentTo(createdConsumptions); await response.Content.ReadFromJsonAsync<GetConsumptions.ApiResponse>();
apiResponse.Should().NotBeNull();
apiResponse.Consumptions.Should().HaveCount(createdConsumptions.Count);
apiResponse.Consumptions.Should().BeEquivalentTo(createdConsumptions, o => o.ExcludingMissingMembers());
apiResponse.Consumptions
.Select(x => x.Car.Id)
.Should()
.BeEquivalentTo(createdConsumptions, o => o.ExcludingMissingMembers());
} }
[Fact] [Fact]
@@ -56,26 +64,32 @@ public class GetConsumptionsTests : IAsyncLifetime
// Assert // Assert
response.StatusCode.Should().Be(HttpStatusCode.OK); response.StatusCode.Should().Be(HttpStatusCode.OK);
var apiResponse = await response.Content.ReadFromJsonAsync<GetConsumptions.ApiResponse>(); GetConsumptions.ApiResponse? apiResponse =
await response.Content.ReadFromJsonAsync<GetConsumptions.ApiResponse>();
apiResponse!.Consumptions.Should().BeEmpty(); apiResponse!.Consumptions.Should().BeEmpty();
} }
private async Task<CreateConsumption.Response> CreateConsumptionAsync() private async Task<CreateConsumption.Response> CreateConsumptionAsync()
{ {
CreateCar.Response createdCarResponse = await CreateCarAsync(); CreateCar.Response createdCarResponse = await CreateCarAsync();
CreateConsumption.Request createConsumptionRequest = _consumptionFaker.CreateConsumptionRequest(createdCarResponse.Id); CreateConsumption.Request createConsumptionRequest =
using HttpResponseMessage response = await _factory.HttpClient.PostAsJsonAsync("v1/consumptions", createConsumptionRequest); _consumptionFaker.CreateConsumptionRequest(createdCarResponse.Id);
using HttpResponseMessage response =
await _factory.HttpClient.PostAsJsonAsync("v1/consumptions", createConsumptionRequest);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
var createdConsumption = await response.Content.ReadFromJsonAsync<CreateConsumption.Response>(); CreateConsumption.Response? createdConsumption =
await response.Content.ReadFromJsonAsync<CreateConsumption.Response>();
return createdConsumption!; return createdConsumption!;
} }
private async Task<CreateCar.Response> CreateCarAsync() private async Task<CreateCar.Response> CreateCarAsync()
{ {
CreateCar.Request createCarRequest = new CarFaker().CreateCarRequest(); CreateCar.Request createCarRequest = new CarFaker().CreateCarRequest();
using HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest); using HttpResponseMessage createCarResponse =
await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
createCarResponse.EnsureSuccessStatusCode(); createCarResponse.EnsureSuccessStatusCode();
var createdCarResponse = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>(); CreateCar.Response? createdCarResponse =
await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
return createdCarResponse!; return createdCarResponse!;
} }