feat: add minimal trips list, create trip, and members ui

This commit is contained in:
Bastian Wagner
2026-08-17 15:49:32 +02:00
parent bd91a3da4a
commit 6e991bfd00
12 changed files with 355 additions and 3 deletions

View File

@@ -1,5 +1,8 @@
<main class="app-shell"> <main class="app-shell">
<h1>Travel Planner</h1> <h1>Travel Planner</h1>
<p>Reisen planen, gemeinsam entscheiden.</p> <p>Reisen planen, gemeinsam entscheiden.</p>
<nav>
<a routerLink="/trips">Reisen</a>
</nav>
<router-outlet /> <router-outlet />
</main> </main>

View File

@@ -1,4 +1,17 @@
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
import { Callback } from './auth/callback/callback'; import { Callback } from './auth/callback/callback';
import { authGuard } from './auth/auth.guard';
export const routes: Routes = [{ path: 'auth/callback', component: Callback }]; export const routes: Routes = [
{ path: 'auth/callback', component: Callback },
{
path: 'trips',
canActivate: [authGuard],
loadComponent: () => import('./trips/trips-list/trips-list').then((m) => m.TripsList),
},
{
path: 'trips/:tripId',
canActivate: [authGuard],
loadComponent: () => import('./trips/trip-detail/trip-detail').then((m) => m.TripDetail),
},
];

View File

@@ -1,10 +1,12 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { App } from './app'; import { App } from './app';
describe('App', () => { describe('App', () => {
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [App], imports: [App],
providers: [provideRouter([])],
}).compileComponents(); }).compileComponents();
}); });

View File

@@ -1,9 +1,9 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router'; import { RouterLink, RouterOutlet } from '@angular/router';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [RouterOutlet], imports: [RouterOutlet, RouterLink],
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.scss', styleUrl: './app.scss',
}) })

View File

@@ -0,0 +1,16 @@
<section class="trip-detail">
@if (trip(); as trip) {
<h2>{{ trip.name }}</h2>
}
@if (conflictError()) {
<p class="error">Die Reise wurde zwischenzeitlich von jemand anderem geändert. Bitte neu laden.</p>
}
<h3>Mitglieder</h3>
<ul>
@for (member of members(); track member.id) {
<li>{{ member.userId }} ({{ member.role }})</li>
}
</ul>
</section>

View File

@@ -0,0 +1,60 @@
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute } from '@angular/router';
import { of } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { TripDetail } from './trip-detail';
import { TripsApiService } from '../trips-api.service';
describe('TripDetail', () => {
it('renders the trip name and members for the routed tripId', async () => {
const tripsApi = {
getTrip: vi.fn().mockReturnValue(of({ id: 't1', name: 'Slovenia 2027', version: 1 })),
listMembers: vi.fn().mockReturnValue(of([{ id: 'm1', userId: 'u1', role: 'OWNER', status: 'ACTIVE' }])),
updateTrip: vi.fn(),
};
await TestBed.configureTestingModule({
imports: [TripDetail],
providers: [
{ provide: TripsApiService, useValue: tripsApi },
{ provide: ActivatedRoute, useValue: { snapshot: { paramMap: { get: () => 't1' } } } },
],
}).compileComponents();
const fixture = TestBed.createComponent(TripDetail);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
expect(tripsApi.getTrip).toHaveBeenCalledWith('t1');
expect(tripsApi.listMembers).toHaveBeenCalledWith('t1');
expect(fixture.nativeElement.textContent).toContain('Slovenia 2027');
});
it('shows a plain error message on a 409 optimistic-locking conflict', async () => {
const tripsApi = {
getTrip: vi.fn().mockReturnValue(of({ id: 't1', name: 'Slovenia 2027', version: 1 })),
listMembers: vi.fn().mockReturnValue(of([])),
updateTrip: vi.fn().mockReturnValue({
subscribe: (observer: { error: (err: unknown) => void }) => observer.error({ status: 409 }),
}),
};
await TestBed.configureTestingModule({
imports: [TripDetail],
providers: [
{ provide: TripsApiService, useValue: tripsApi },
{ provide: ActivatedRoute, useValue: { snapshot: { paramMap: { get: () => 't1' } } } },
],
}).compileComponents();
const fixture = TestBed.createComponent(TripDetail);
const component = fixture.componentInstance;
fixture.detectChanges();
await fixture.whenStable();
component.rename('New name');
expect(component.conflictError()).toBe(true);
});
});

View File

@@ -0,0 +1,40 @@
import { Component, inject, OnInit, signal } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { HttpErrorResponse } from '@angular/common/http';
import { Trip, TripMember, TripsApiService } from '../trips-api.service';
@Component({
selector: 'app-trip-detail',
standalone: true,
imports: [],
templateUrl: './trip-detail.html',
})
export class TripDetail implements OnInit {
private readonly tripsApi = inject(TripsApiService);
private readonly route = inject(ActivatedRoute);
private tripId!: string;
readonly trip = signal<Trip | undefined>(undefined);
readonly members = signal<TripMember[]>([]);
readonly conflictError = signal(false);
ngOnInit(): void {
this.tripId = this.route.snapshot.paramMap.get('tripId') as string;
this.tripsApi.getTrip(this.tripId).subscribe((trip) => this.trip.set(trip));
this.tripsApi.listMembers(this.tripId).subscribe((members) => this.members.set(members));
}
rename(name: string): void {
const current = this.trip();
if (!current) return;
this.conflictError.set(false);
this.tripsApi.updateTrip(this.tripId, { name, version: current.version }).subscribe({
next: (updated) => this.trip.set(updated),
error: (error: HttpErrorResponse) => {
if (error.status === 409) {
this.conflictError.set(true);
}
},
});
}
}

View File

@@ -0,0 +1,56 @@
import { TestBed } from '@angular/core/testing';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { provideHttpClient } from '@angular/common/http';
import { describe, expect, it, afterEach } from 'vitest';
import { TripsApiService } from './trips-api.service';
describe('TripsApiService', () => {
let service: TripsApiService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(TripsApiService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('lists trips from /api/v1/trips', () => {
let result: unknown;
service.listTrips().subscribe((trips) => (result = trips));
const req = httpMock.expectOne('/api/v1/trips');
expect(req.request.method).toBe('GET');
req.flush([{ id: 't1', name: 'Slovenia 2027' }]);
expect(result).toEqual([{ id: 't1', name: 'Slovenia 2027' }]);
});
it('creates a trip via POST /api/v1/trips', () => {
service.createTrip({ name: 'Slovenia 2027' }).subscribe();
const req = httpMock.expectOne('/api/v1/trips');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual({ name: 'Slovenia 2027' });
req.flush({ id: 't1', name: 'Slovenia 2027' });
});
it('gets a single trip via GET /api/v1/trips/:id', () => {
service.getTrip('t1').subscribe();
const req = httpMock.expectOne('/api/v1/trips/t1');
expect(req.request.method).toBe('GET');
req.flush({ id: 't1' });
});
it('lists members via GET /api/v1/trips/:id/members', () => {
service.listMembers('t1').subscribe();
const req = httpMock.expectOne('/api/v1/trips/t1/members');
expect(req.request.method).toBe('GET');
req.flush([]);
});
});

View File

@@ -0,0 +1,69 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
export type TripStatus = 'DRAFT' | 'PLANNING' | 'BOOKING' | 'UPCOMING' | 'ACTIVE' | 'COMPLETED' | 'ARCHIVED';
export interface Trip {
id: string;
name: string;
description: string | null;
ownerId: string;
startDate: string | null;
endDate: string | null;
status: TripStatus;
planningStage: string | null;
currency: string;
version: number;
createdAt: string;
updatedAt: string;
}
export interface CreateTripDto {
name: string;
description?: string;
startDate?: string;
endDate?: string;
currency?: string;
}
export interface UpdateTripDto {
name?: string;
description?: string | null;
version: number;
}
export interface TripMember {
id: string;
tripId: string;
userId: string;
role: 'OWNER' | 'MEMBER';
status: 'INVITED' | 'ACTIVE' | 'DECLINED';
}
@Injectable({ providedIn: 'root' })
export class TripsApiService {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.apiBaseUrl}/trips`;
listTrips(): Observable<Trip[]> {
return this.http.get<Trip[]>(this.baseUrl);
}
createTrip(dto: CreateTripDto): Observable<Trip> {
return this.http.post<Trip>(this.baseUrl, dto);
}
getTrip(tripId: string): Observable<Trip> {
return this.http.get<Trip>(`${this.baseUrl}/${tripId}`);
}
updateTrip(tripId: string, dto: UpdateTripDto): Observable<Trip> {
return this.http.patch<Trip>(`${this.baseUrl}/${tripId}`, dto);
}
listMembers(tripId: string): Observable<TripMember[]> {
return this.http.get<TripMember[]>(`${this.baseUrl}/${tripId}/members`);
}
}

View File

@@ -0,0 +1,14 @@
<section class="trips-list">
<h2>Reisen</h2>
<ul>
@for (trip of trips(); track trip.id) {
<li><a [routerLink]="['/trips', trip.id]">{{ trip.name }}</a></li>
}
</ul>
<form (ngSubmit)="createTrip()">
<input type="text" [ngModel]="newTripName()" (ngModelChange)="newTripName.set($event)" name="tripName" placeholder="Neue Reise" />
<button type="submit">Reise erstellen</button>
</form>
</section>

View File

@@ -0,0 +1,49 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { TripsList } from './trips-list';
import { TripsApiService } from '../trips-api.service';
describe('TripsList', () => {
it('renders trip names returned by TripsApiService', async () => {
const tripsApi = {
listTrips: vi.fn().mockReturnValue(of([{ id: 't1', name: 'Slovenia 2027' }])),
createTrip: vi.fn(),
};
await TestBed.configureTestingModule({
imports: [TripsList],
providers: [{ provide: TripsApiService, useValue: tripsApi }, provideRouter([])],
}).compileComponents();
const fixture = TestBed.createComponent(TripsList);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Slovenia 2027');
});
it('calls createTrip when the create form is submitted', async () => {
const tripsApi = {
listTrips: vi.fn().mockReturnValue(of([])),
createTrip: vi.fn().mockReturnValue(of({ id: 't2', name: 'New Trip' })),
};
await TestBed.configureTestingModule({
imports: [TripsList],
providers: [{ provide: TripsApiService, useValue: tripsApi }, provideRouter([])],
}).compileComponents();
const fixture = TestBed.createComponent(TripsList);
const component = fixture.componentInstance;
fixture.detectChanges();
await fixture.whenStable();
component.newTripName.set('New Trip');
component.createTrip();
expect(tripsApi.createTrip).toHaveBeenCalledWith({ name: 'New Trip' });
});
});

View File

@@ -0,0 +1,30 @@
import { Component, inject, OnInit, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { Trip, TripsApiService } from '../trips-api.service';
@Component({
selector: 'app-trips-list',
standalone: true,
imports: [RouterLink, FormsModule],
templateUrl: './trips-list.html',
})
export class TripsList implements OnInit {
private readonly tripsApi = inject(TripsApiService);
readonly trips = signal<Trip[]>([]);
readonly newTripName = signal('');
ngOnInit(): void {
this.tripsApi.listTrips().subscribe((trips) => this.trips.set(trips));
}
createTrip(): void {
const name = this.newTripName().trim();
if (!name) return;
this.tripsApi.createTrip({ name }).subscribe((trip) => {
this.trips.update((trips) => [...trips, trip]);
this.newTripName.set('');
});
}
}