diff --git a/frontend/src/app/app.html b/frontend/src/app/app.html index 003ae49..3da6811 100644 --- a/frontend/src/app/app.html +++ b/frontend/src/app/app.html @@ -1,5 +1,8 @@

Travel Planner

Reisen planen, gemeinsam entscheiden.

+
diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 40bd1a7..9c31733 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -1,4 +1,17 @@ import { Routes } from '@angular/router'; 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), + }, +]; diff --git a/frontend/src/app/app.spec.ts b/frontend/src/app/app.spec.ts index 1821efb..d77bd18 100644 --- a/frontend/src/app/app.spec.ts +++ b/frontend/src/app/app.spec.ts @@ -1,10 +1,12 @@ import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; import { App } from './app'; describe('App', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [App], + providers: [provideRouter([])], }).compileComponents(); }); diff --git a/frontend/src/app/app.ts b/frontend/src/app/app.ts index 372b06c..597f18f 100644 --- a/frontend/src/app/app.ts +++ b/frontend/src/app/app.ts @@ -1,9 +1,9 @@ import { Component } from '@angular/core'; -import { RouterOutlet } from '@angular/router'; +import { RouterLink, RouterOutlet } from '@angular/router'; @Component({ selector: 'app-root', - imports: [RouterOutlet], + imports: [RouterOutlet, RouterLink], templateUrl: './app.html', styleUrl: './app.scss', }) diff --git a/frontend/src/app/trips/trip-detail/trip-detail.html b/frontend/src/app/trips/trip-detail/trip-detail.html new file mode 100644 index 0000000..8f761df --- /dev/null +++ b/frontend/src/app/trips/trip-detail/trip-detail.html @@ -0,0 +1,16 @@ +
+ @if (trip(); as trip) { +

{{ trip.name }}

+ } + + @if (conflictError()) { +

Die Reise wurde zwischenzeitlich von jemand anderem geƤndert. Bitte neu laden.

+ } + +

Mitglieder

+ +
diff --git a/frontend/src/app/trips/trip-detail/trip-detail.spec.ts b/frontend/src/app/trips/trip-detail/trip-detail.spec.ts new file mode 100644 index 0000000..17e2b63 --- /dev/null +++ b/frontend/src/app/trips/trip-detail/trip-detail.spec.ts @@ -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); + }); +}); diff --git a/frontend/src/app/trips/trip-detail/trip-detail.ts b/frontend/src/app/trips/trip-detail/trip-detail.ts new file mode 100644 index 0000000..721957c --- /dev/null +++ b/frontend/src/app/trips/trip-detail/trip-detail.ts @@ -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(undefined); + readonly members = signal([]); + 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); + } + }, + }); + } +} diff --git a/frontend/src/app/trips/trips-api.service.spec.ts b/frontend/src/app/trips/trips-api.service.spec.ts new file mode 100644 index 0000000..f5532ac --- /dev/null +++ b/frontend/src/app/trips/trips-api.service.spec.ts @@ -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([]); + }); +}); diff --git a/frontend/src/app/trips/trips-api.service.ts b/frontend/src/app/trips/trips-api.service.ts new file mode 100644 index 0000000..247da59 --- /dev/null +++ b/frontend/src/app/trips/trips-api.service.ts @@ -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 { + return this.http.get(this.baseUrl); + } + + createTrip(dto: CreateTripDto): Observable { + return this.http.post(this.baseUrl, dto); + } + + getTrip(tripId: string): Observable { + return this.http.get(`${this.baseUrl}/${tripId}`); + } + + updateTrip(tripId: string, dto: UpdateTripDto): Observable { + return this.http.patch(`${this.baseUrl}/${tripId}`, dto); + } + + listMembers(tripId: string): Observable { + return this.http.get(`${this.baseUrl}/${tripId}/members`); + } +} diff --git a/frontend/src/app/trips/trips-list/trips-list.html b/frontend/src/app/trips/trips-list/trips-list.html new file mode 100644 index 0000000..c512875 --- /dev/null +++ b/frontend/src/app/trips/trips-list/trips-list.html @@ -0,0 +1,14 @@ +
+

Reisen

+ + + +
+ + +
+
diff --git a/frontend/src/app/trips/trips-list/trips-list.spec.ts b/frontend/src/app/trips/trips-list/trips-list.spec.ts new file mode 100644 index 0000000..efe7169 --- /dev/null +++ b/frontend/src/app/trips/trips-list/trips-list.spec.ts @@ -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' }); + }); +}); diff --git a/frontend/src/app/trips/trips-list/trips-list.ts b/frontend/src/app/trips/trips-list/trips-list.ts new file mode 100644 index 0000000..88fe2a2 --- /dev/null +++ b/frontend/src/app/trips/trips-list/trips-list.ts @@ -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([]); + 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(''); + }); + } +}