feat: add minimal trips list, create trip, and members ui
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
})
|
})
|
||||||
|
|||||||
16
frontend/src/app/trips/trip-detail/trip-detail.html
Normal file
16
frontend/src/app/trips/trip-detail/trip-detail.html
Normal 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>
|
||||||
60
frontend/src/app/trips/trip-detail/trip-detail.spec.ts
Normal file
60
frontend/src/app/trips/trip-detail/trip-detail.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
40
frontend/src/app/trips/trip-detail/trip-detail.ts
Normal file
40
frontend/src/app/trips/trip-detail/trip-detail.ts
Normal 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
56
frontend/src/app/trips/trips-api.service.spec.ts
Normal file
56
frontend/src/app/trips/trips-api.service.spec.ts
Normal 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
69
frontend/src/app/trips/trips-api.service.ts
Normal file
69
frontend/src/app/trips/trips-api.service.ts
Normal 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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
frontend/src/app/trips/trips-list/trips-list.html
Normal file
14
frontend/src/app/trips/trips-list/trips-list.html
Normal 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>
|
||||||
49
frontend/src/app/trips/trips-list/trips-list.spec.ts
Normal file
49
frontend/src/app/trips/trips-list/trips-list.spec.ts
Normal 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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
30
frontend/src/app/trips/trips-list/trips-list.ts
Normal file
30
frontend/src/app/trips/trips-list/trips-list.ts
Normal 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('');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user