fix guest component
Some checks failed
Angular Build / build (push) Has been cancelled

This commit is contained in:
2026-03-20 19:14:59 +01:00
parent 902f1e97ee
commit f2986dd420
11 changed files with 229 additions and 30 deletions

View File

@@ -6,6 +6,7 @@
"build": "ng build --configuration production",
"build:dev": "ng build --configuration development",
"deploy": "ng build --configuration production && firebase deploy",
"deploy-beta": "ng b && firebase hosting:channel:deploy beta",
"test": "ng test",
"lint": "ng lint --fix",
"update": "ng update @angular/cdk @angular/cli @angular/core @angular/material && ncu -u && npm i && npm fix"

View File

@@ -1,4 +1,5 @@
import {TestBed} from '@angular/core/testing';
import {Firestore} from '@angular/fire/firestore';
import {of} from 'rxjs';
import {DbService} from 'src/app/services/db.service';
import {GuestShowDataService} from './guest-show-data.service';
@@ -11,6 +12,7 @@ describe('GuestShowDataService', () => {
let colAddSpy: jasmine.Spy<() => Promise<{id: string}>>;
let colSpy: jasmine.Spy;
let dbServiceSpy: jasmine.SpyObj<DbService>;
let firestoreStub: Firestore;
beforeEach(async () => {
docUpdateSpy = jasmine.createSpy('update').and.resolveTo();
@@ -26,9 +28,13 @@ describe('GuestShowDataService', () => {
dbServiceSpy.doc$.and.returnValue(of({id: 'guest-1'}) as never);
dbServiceSpy.doc.and.callFake(docSpy);
dbServiceSpy.col.and.callFake(colSpy);
firestoreStub = {} as Firestore;
await TestBed.configureTestingModule({
providers: [{provide: DbService, useValue: dbServiceSpy}],
providers: [
{provide: DbService, useValue: dbServiceSpy},
{provide: Firestore, useValue: firestoreStub},
],
});
service = TestBed.inject(GuestShowDataService);

View File

@@ -1,4 +1,5 @@
import {Injectable, inject} from '@angular/core';
import {EnvironmentInjector, Injectable, inject, runInInjectionContext} from '@angular/core';
import {doc, Firestore, getDoc} from '@angular/fire/firestore';
import {Observable} from 'rxjs';
import {shareReplay} from 'rxjs/operators';
import {DbService} from 'src/app/services/db.service';
@@ -9,6 +10,8 @@ import {GuestShow} from './guest-show';
})
export class GuestShowDataService {
private dbService = inject(DbService);
private firestore = inject(Firestore);
private environmentInjector = inject(EnvironmentInjector);
private collection = 'guest';
public list$: Observable<GuestShow[]> = this.dbService.col$<GuestShow>(this.collection).pipe(
@@ -19,6 +22,18 @@ export class GuestShowDataService {
);
public read$: (id: string) => Observable<GuestShow | null> = (id: string): Observable<GuestShow | null> => this.dbService.doc$(`${this.collection}/${id}`);
public read: (id: string) => Promise<GuestShow | null> = async (id: string): Promise<GuestShow | null> => {
const snapshot = await runInInjectionContext(this.environmentInjector, () => getDoc(doc(this.firestore, `${this.collection}/${id}`)));
if (!snapshot.exists()) {
return null;
}
return {
id: snapshot.id,
...(snapshot.data() as Omit<GuestShow, 'id'>),
};
};
public update$: (id: string, data: Partial<GuestShow>) => Promise<void> = async (id: string, data: Partial<GuestShow>): Promise<void> =>
await this.dbService.doc(this.collection + '/' + id).update(data);
public add: (data: Partial<GuestShow>) => Promise<string> = async (data: Partial<GuestShow>): Promise<string> => (await this.dbService.col(this.collection).add(data)).id;

View File

@@ -38,11 +38,15 @@ describe('GuestShowService', () => {
await expectAsync(service.share(show, songs)).toBeResolvedTo(expectedUrl);
expect(guestShowDataServiceSpy.add).toHaveBeenCalledWith({
showType: 'service-worship',
date: show.date,
songs,
});
const [addPayload] = guestShowDataServiceSpy.add.calls.mostRecent().args as [Record<string, unknown>];
expect(addPayload).toEqual(
jasmine.objectContaining({
showType: 'service-worship',
date: show.date,
songs,
})
);
expect(addPayload['updatedAt']).toEqual(jasmine.any(Date));
expect(showServiceSpy.update$).toHaveBeenCalledWith('show-1', {shareId: 'share-1'});
});
@@ -53,11 +57,16 @@ describe('GuestShowService', () => {
await expectAsync(service.share(show, songs)).toBeResolvedTo(expectedUrl);
expect(guestShowDataServiceSpy.update$).toHaveBeenCalledWith('share-9', {
showType: 'service-worship',
date: show.date,
songs,
});
const [shareId, updatePayload] = guestShowDataServiceSpy.update$.calls.mostRecent().args as [string, Record<string, unknown>];
expect(shareId).toBe('share-9');
expect(updatePayload).toEqual(
jasmine.objectContaining({
showType: 'service-worship',
date: show.date,
songs,
})
);
expect(updatePayload['updatedAt']).toEqual(jasmine.any(Date));
expect(guestShowDataServiceSpy.add).not.toHaveBeenCalled();
expect(showServiceSpy.update$).not.toHaveBeenCalled();
});

View File

@@ -15,6 +15,7 @@ export class GuestShowService {
const data = {
showType: show.showType,
date: show.date,
updatedAt: new Date(),
songs: songs,
};
let shareId = show.shareId;

View File

@@ -5,5 +5,6 @@ export interface GuestShow {
id: string;
showType: string;
date: Timestamp;
updatedAt?: Timestamp | Date;
songs: Song[];
}

View File

@@ -1,12 +1,13 @@
@if (show$|async; as show) {
@if (showState$ | async; as state) {
@if (state.status === 'loaded') {
<div class="page">
<div class="title">
<div class="left">{{ show.showType|showType }}</div>
<div class="right">{{ show.date.toDate() | date: 'dd.MM.yyyy' }}</div>
<div class="left">{{ state.show.showType | showType }}</div>
<div class="right">{{ state.show.date | date: 'dd.MM.yyyy' }}</div>
</div>
<div class="view">
<swiper-container scrollbar="true">
@for (song of show.songs; track trackBy(i, song); let i = $index) {
@for (song of state.show.songs; track song.id) {
<swiper-slide class="song-swipe">
<div class="song-title">{{ song.title }}</div>
<div class="legal">
@@ -20,4 +21,17 @@
</swiper-container>
</div>
</div>
} @else if (state.status === 'loading') {
<div class="empty-state">
Gastansicht wird geladen.
</div>
} @else if (state.status === 'not-found') {
<div class="empty-state">
Für diesen Link wurde keine Gastansicht gefunden.
</div>
} @else {
<div class="empty-state">
{{ state.message }}
</div>
}
}

View File

@@ -57,3 +57,13 @@ app-song-text {
margin-bottom: 50px;
min-height: calc(100vh - 150px);
}
.empty-state {
color: var(--text-inverse);
display: flex;
justify-content: center;
align-items: center;
min-height: calc(100vh - 50px);
padding: 24px;
text-align: center;
}

View File

@@ -1,24 +1,97 @@
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {ActivatedRoute} from '@angular/router';
import {BehaviorSubject, of} from 'rxjs';
import {GuestComponent} from './guest.component';
import {GuestShowDataService} from './guest-show-data.service';
describe('GuestComponent', () => {
let component: GuestComponent;
let fixture: ComponentFixture<GuestComponent>;
let guestShowDataServiceSpy: jasmine.SpyObj<GuestShowDataService>;
let guestShowSubject: BehaviorSubject<unknown>;
beforeEach(async () => {
guestShowSubject = new BehaviorSubject<unknown>({
id: 'guest-1',
showType: 'service-worship',
date: {
toDate: () => new Date('2026-03-20T00:00:00Z'),
},
songs: [
{
id: 'song-1',
title: 'Titel',
text: 'Text',
artist: 'Artist',
},
],
});
guestShowDataServiceSpy = jasmine.createSpyObj<GuestShowDataService>('GuestShowDataService', ['read', 'read$']);
guestShowDataServiceSpy.read.and.resolveTo({
id: 'guest-1',
showType: 'service-worship',
date: {
toDate: () => new Date('2026-03-20T00:00:00Z'),
},
songs: [
{
id: 'song-1',
title: 'Titel',
text: 'Text',
artist: 'Artist',
},
],
} as never);
guestShowDataServiceSpy.read$.and.returnValue(guestShowSubject.asObservable() as never);
await TestBed.configureTestingModule({
imports: [GuestComponent],
providers: [
{provide: ActivatedRoute, useValue: {params: of({id: 'guest-1'})}},
{provide: GuestShowDataService, useValue: guestShowDataServiceSpy},
],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(GuestComponent);
component = fixture.componentInstance;
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
});
it('should create', () => {
void expect(component).toBeTruthy();
expect(component).toBeTruthy();
});
it('should load and render the guest show', () => {
expect(guestShowDataServiceSpy.read).toHaveBeenCalledWith('guest-1');
expect(guestShowDataServiceSpy.read$).toHaveBeenCalledWith('guest-1');
expect(fixture.nativeElement.textContent).toContain('Titel');
expect(fixture.nativeElement.textContent).toContain('20.03.2026');
});
it('should update the rendered guest show when live data changes', async () => {
guestShowSubject.next({
id: 'guest-1',
showType: 'service-worship',
date: {
toDate: () => new Date('2026-03-21T00:00:00Z'),
},
songs: [
{
id: 'song-2',
title: 'Neuer Titel',
text: 'Neuer Text',
artist: 'Neue Artistin',
},
],
});
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Neuer Titel');
expect(fixture.nativeElement.textContent).toContain('21.03.2026');
});
});

View File

@@ -1,12 +1,13 @@
import {Component, CUSTOM_ELEMENTS_SCHEMA, inject} from '@angular/core';
import {AsyncPipe, DatePipe} from '@angular/common';
import {GuestShowDataService} from './guest-show-data.service';
import {ActivatedRoute} from '@angular/router';
import {map, switchMap} from 'rxjs/operators';
import {catchError, map, switchMap} from 'rxjs/operators';
import {Song} from '../songs/services/song';
import {ConfigService} from '../../services/config.service';
import {AsyncPipe, DatePipe} from '@angular/common';
import {SongTextComponent} from '../../widget-modules/components/song-text/song-text.component';
import {ShowTypePipe} from '../../widget-modules/pipes/show-type-translater/show-type.pipe';
import {concat, from, Observable, of} from 'rxjs';
import {GuestShow} from './guest-show';
@Component({
selector: 'app-guest',
@@ -18,13 +19,81 @@ import {ShowTypePipe} from '../../widget-modules/pipes/show-type-translater/show
export class GuestComponent {
private currentRoute = inject(ActivatedRoute);
private service = inject(GuestShowDataService);
private configService = inject(ConfigService);
public show$ = this.currentRoute.params.pipe(
public showState$: Observable<GuestShowState> = this.currentRoute.params.pipe(
map(param => param.id as string),
switchMap(id => this.service.read$(id))
switchMap(id =>
concat(
of<GuestShowState>({status: 'loading'}),
from(this.service.read(id)).pipe(
switchMap(show => {
const normalizedShow = this.normalizeShow(show);
if (!normalizedShow) {
return of<GuestShowState>({status: 'not-found'});
}
return concat(
of<GuestShowState>({status: 'loaded', show: normalizedShow}),
this.service.read$(id).pipe(
map(liveShow => this.normalizeShow(liveShow)),
map(liveShow => (liveShow ? ({status: 'loaded', show: liveShow} as GuestShowState) : ({status: 'not-found'} as GuestShowState))),
catchError(() => of<GuestShowState>({status: 'error', message: 'Live-Aktualisierung fehlgeschlagen.'}))
)
);
}),
catchError(() => of<GuestShowState>({status: 'error', message: 'Gastansicht konnte nicht geladen werden.'}))
)
)
)
);
public config$ = this.configService.get$();
public trackBy = (index: number, show: Song) => show.id;
private normalizeShow(show: GuestShow | null): GuestShowView | null {
if (!show) {
return null;
}
return {
...show,
date: this.toDate(show.date),
updatedAt: this.toDate(show.updatedAt ?? null),
songs: Array.isArray(show.songs) ? show.songs : [],
};
}
private toDate(value: unknown): Date | null {
if (value instanceof Date) {
return value;
}
if (typeof value === 'object' && value !== null) {
if ('toDate' in value && typeof value.toDate === 'function') {
return value.toDate() as Date;
}
if ('seconds' in value && typeof value.seconds === 'number') {
return new Date(value.seconds * 1000);
}
}
if (typeof value === 'string' || typeof value === 'number') {
const parsedDate = new Date(value);
return Number.isNaN(parsedDate.getTime()) ? null : parsedDate;
}
return null;
}
}
interface GuestShowView extends Omit<GuestShow, 'date' | 'updatedAt'> {
date: Date | null;
updatedAt: Date | null;
}
type GuestShowState =
| {status: 'loading'}
| {status: 'not-found'}
| {status: 'error'; message: string}
| {status: 'loaded'; show: GuestShowView};

View File

@@ -11,7 +11,7 @@ export class ShowTypePipe implements PipeTransform {
case 'service-praise':
return 'Gottesdienst Lobpreis';
case 'home-group-big':
return 'großer Hauskreis';
return 'Großer Hauskreis';
case 'home-group':
return 'Hauskreis';
case 'prayer-group':
@@ -21,9 +21,9 @@ export class ShowTypePipe implements PipeTransform {
case 'kids-group':
return 'Kinderkreis';
case 'misc-public':
return 'sonstige öffentliche Veranstaltung';
return 'Sonstige öffentliche Veranstaltung';
case 'misc-private':
return 'sonstige private Veranstaltung';
return 'Sonstige private Veranstaltung';
}
return 'unbekannter Veranstaltungstyp';