This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ export class GuestShowService {
|
||||
const data = {
|
||||
showType: show.showType,
|
||||
date: show.date,
|
||||
updatedAt: new Date(),
|
||||
songs: songs,
|
||||
};
|
||||
let shareId = show.shareId;
|
||||
|
||||
@@ -5,5 +5,6 @@ export interface GuestShow {
|
||||
id: string;
|
||||
showType: string;
|
||||
date: Timestamp;
|
||||
updatedAt?: Timestamp | Date;
|
||||
songs: Song[];
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user