7 Commits

Author SHA1 Message Date
e3203d0c38 fix login redirect 2026-03-15 22:54:52 +01:00
2406d41dcb fix tests 2026-03-15 22:33:06 +01:00
2d4f1ee314 fix linting 2026-03-15 22:23:58 +01:00
67884e4638 add song reporting 2026-03-15 22:23:11 +01:00
e4f829d0c8 fix template loop references 2026-03-15 13:33:36 +01:00
9bbabb18aa fix song header filter 2026-03-15 13:21:17 +01:00
ab535d48b9 optimize song usage 2026-03-15 13:19:20 +01:00
100 changed files with 2073 additions and 1528 deletions

View File

@@ -8,6 +8,14 @@ If `songUsage` needs to be rebuilt from all existing shows, log in with a user t
await window.wgeneratorAdmin.rebuildSongUsage()
```
If the `songIds` index on shows needs to be backfilled for tooltip usage in song details, run:
```js
await window.wgeneratorAdmin.rebuildShowSongIds()
```
The command logs progress in the browser console while it runs and prints the final summary when finished.
The migration:
- resets `songUsage` for all users
@@ -16,4 +24,11 @@ The migration:
It returns a summary object with processed user, show and show-song counts.
The show index migration:
- scans all shows and all `shows/{id}/songs` entries
- writes the distinct `songIds` array to each show document
It returns a summary object with processed show and show-song counts.
This is intended as a manual one-off migration and is read-heavy by design.

View File

@@ -3,4 +3,3 @@
<div [@fader]="o.isActivated ? o.activatedRoute : ''" class="content">
<router-outlet #o="outlet"></router-outlet>
</div>

View File

@@ -4,10 +4,7 @@
<div class="text">
<div class="welcome">WILLKOMMEN</div>
<div class="name">{{ user.name }}</div>
<div class="roles">
Es wurden noch keine Berechtigungen zugeteilt, bitte wende Dich an den
Administrator!
</div>
<div class="roles">Es wurden noch keine Berechtigungen zugeteilt, bitte wende Dich an den Administrator!</div>
</div>
}
</div>

View File

@@ -1,5 +1,5 @@
@if (show$|async; as show) {
<div class="page">
<div class="page">
<div class="title">
<div class="left">{{ show.showType|showType }}</div>
<div class="right">{{ show.date.toDate() | date: 'dd.MM.yyyy' }}</div>
@@ -7,22 +7,17 @@
<div class="view">
<swiper-container scrollbar="true">
@for (song of show.songs; track trackBy(i, song); let i = $index) {
<swiper-slide
class="song-swipe">
<swiper-slide class="song-swipe">
<div class="song-title">{{ song.title }}</div>
<div class="legal">
@if (song.artist) {
<p>{{ song.artist }}</p>
}
</div>
<app-song-text
[text]="song.text"
></app-song-text>
<app-song-text [text]="song.text"></app-song-text>
</swiper-slide>
}
</swiper-container>
</div>
</div>
</div>
}

View File

@@ -1,27 +1,17 @@
@if (song) {
@if (song.artist) {
<p>{{ song.artist }}</p>
}
@if (song.label) {
<p>{{ song.label }}</p>
}
@if (song.termsOfUse) {
<p class="terms-of-use">{{ song.termsOfUse }}</p>
}
@if (song.origin) {
<p>{{ song.origin }}</p>
}
@if (song.legalOwnerId) {
<div>
@if (song) { @if (song.artist) {
<p>{{ song.artist }}</p>
} @if (song.label) {
<p>{{ song.label }}</p>
} @if (song.termsOfUse) {
<p class="terms-of-use">{{ song.termsOfUse }}</p>
} @if (song.origin) {
<p>{{ song.origin }}</p>
} @if (song.legalOwnerId) {
<div>
@if (song.legalOwner === 'CCLI' && config) {
<p>
CCLI-Liednummer {{ song.legalOwnerId }}, CCLI-Lizenznummer
{{ config.ccliLicenseId }}
</p>
}
@if (song.legalOwner !== 'CCLI') {
<p>CCLI-Liednummer {{ song.legalOwnerId }}, CCLI-Lizenznummer {{ config.ccliLicenseId }}</p>
} @if (song.legalOwner !== 'CCLI') {
<p>Liednummer {{ song.legalOwnerId }}</p>
}
</div>
}
}
</div>
} }

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -1,6 +1,6 @@
<div class="fullscreen background"></div>
@if (showType) {
<div [style.font-size.px]="zoom" class="fullscreen background">
<div [style.font-size.px]="zoom" class="fullscreen background">
<div [class.visible]="presentationBackground==='blue'" class="bg-blue fullscreen bg-image"></div>
<div [class.visible]="presentationBackground==='green'" class="bg-green fullscreen bg-image"></div>
<div [class.visible]="presentationBackground==='leder'" class="bg-leder fullscreen bg-image"></div>
@@ -20,14 +20,12 @@
<div>{{ showType | showType }}</div>
<div class="date">{{ date | date: "dd.MM.yyyy" }}</div>
</div>
}
@if (songId === 'dynamicText') {
} @if (songId === 'dynamicText') {
<div @songSwitch class="start fullscreen dynamic-text">
<div>{{ presentationDynamicCaption }}</div>
<div class="date">{{ presentationDynamicText }}</div>
</div>
}
@if (song && songId !== 'title' && songId !== 'empty' && songId !== 'dynamicText') {
} @if (song && songId !== 'title' && songId !== 'empty' && songId !== 'dynamicText') {
<app-song-text
[@songSwitch]="songId"
[fullscreen]="true"
@@ -38,13 +36,8 @@
[text]="song.text"
chordMode="hide"
></app-song-text>
} @if (song && songId !== 'title' && songId !== 'empty' && songId !== 'dynamicText') {
<app-legal [@songSwitch]="songId" [config]="config$ | async" [song]="song"></app-legal>
}
@if (song && songId !== 'title' && songId !== 'empty' && songId !== 'dynamicText') {
<app-legal
[@songSwitch]="songId"
[config]="config$ | async"
[song]="song"
></app-legal>
}
</div>
</div>
}

View File

@@ -1,23 +1,14 @@
@if (show) {
<div @fade>
<app-card [closeIcon]="faIcon" [heading]="show.showType | showType"
[subheading]="show.date.toDate() | date:'dd.MM.yyyy'" closeLink="/presentation/select">
<div @fade>
<app-card [closeIcon]="faIcon" [heading]="show.showType | showType" [subheading]="show.date.toDate() | date:'dd.MM.yyyy'" closeLink="/presentation/select">
@if (!progress) {
<div class="song">
@if (show) {
<div class="song-parts">
<div
(click)="onSectionClick('title', -1, show.id)"
[class.active]="show.presentationSongId === 'title'"
class="song-part"
>
<div (click)="onSectionClick('title', -1, show.id)" [class.active]="show.presentationSongId === 'title'" class="song-part">
<div class="head">Veranstaltung</div>
</div>
<div
(click)="onSectionClick('empty', -1, show.id)"
[class.active]="show.presentationSongId === 'empty'"
class="song-part"
>
<div (click)="onSectionClick('empty', -1, show.id)" [class.active]="show.presentationSongId === 'empty'" class="song-part">
<div class="head">Leer</div>
</div>
</div>
@@ -26,18 +17,12 @@
@for (song of presentationSongs; track trackBy($index, song)) {
<div class="song">
@if (show) {
<div
[class.active]="show.presentationSongId === song.id"
class="title song-part"
>
<div (click)="onSectionClick(song.id, -1, show.id)" class="head">
{{ song.title }}
<div [class.active]="show.presentationSongId === song.id" class="title song-part">
<div (click)="onSectionClick(song.id, -1, show.id)" class="head">{{ song.title }}</div>
</div>
</div>
}
@if (show) {
} @if (show) {
<div class="song-parts">
@for (section of song.sections; track section; let i = $index) {
@for (section of song.sections; track section.type + '-' + section.number + '-' + $index; let i = $index) {
<div
(click)="onSectionClick(song.id, i, show.id)"
[class.active]="
@@ -46,9 +31,7 @@
"
class="song-part"
>
<div class="head">
{{ section.type | sectionType }} {{ section.number + 1 }}
</div>
<div class="head">{{ section.type | sectionType }} {{ section.number + 1 }}</div>
<div class="fragment">{{ getFirstLine(section) }}</div>
</div>
}
@@ -58,27 +41,17 @@
}
<div class="song">
@if (show) {
<div
[class.active]="show.presentationSongId === 'dynamicText'"
class="title song-part"
>
<div (click)="onSectionClick('dynamicText', -1, show.id)" class="head">
Freier Text
</div>
<div [class.active]="show.presentationSongId === 'dynamicText'" class="title song-part">
<div (click)="onSectionClick('dynamicText', -1, show.id)" class="head">Freier Text</div>
</div>
}
<mat-form-field appearance="outline">
<mat-label>Überschrift</mat-label>
<input (ngModelChange)="onDynamicCaption($event, show.id)" [ngModel]="show.presentationDynamicCaption"
autocomplete="off" id="dynamic-caption"
matInput
type="text">
<input (ngModelChange)="onDynamicCaption($event, show.id)" [ngModel]="show.presentationDynamicCaption" autocomplete="off" id="dynamic-caption" matInput type="text" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Text</mat-label>
<textarea (ngModelChange)="onDynamicText($event, show.id)" [ngModel]="show.presentationDynamicText"
autocomplete="off" id="dynamic-text"
matInput></textarea>
<textarea (ngModelChange)="onDynamicText($event, show.id)" [ngModel]="show.presentationDynamicText" autocomplete="off" id="dynamic-text" matInput></textarea>
</mat-form-field>
</div>
@if (show) {
@@ -89,9 +62,7 @@
</button>
<mat-form-field appearance="outline">
<mat-label>Hintergrund</mat-label>
<mat-select
(ngModelChange)="onBackground($event, show.id)"
[ngModel]="show.presentationBackground">
<mat-select (ngModelChange)="onBackground($event, show.id)" [ngModel]="show.presentationBackground">
<mat-option value="none">kein Hintergrund</mat-option>
<mat-option value="blue">Sternenhimmel</mat-option>
<mat-option value="green">Blätter</mat-option>
@@ -100,29 +71,13 @@
<mat-option value="bible">Bibel</mat-option>
</mat-select>
</mat-form-field>
<mat-slider
#slider
[max]="100"
[min]="10"
[step]="2"
class="zoom-slider"
color="primary"
ngDefaultControl
><input (ngModelChange)="onZoom($event, show.id)"
[ngModel]="show.presentationZoom"
matSliderThumb>
<mat-slider #slider [max]="100" [min]="10" [step]="2" class="zoom-slider" color="primary" ngDefaultControl
><input (ngModelChange)="onZoom($event, show.id)" [ngModel]="show.presentationZoom" matSliderThumb />
</mat-slider>
</div>
}
@if (show) {
<app-add-song
[addedLive]="true"
[showSongs]="showSongs"
[show]="show"
[songs]="songs$|async"
></app-add-song>
}
}
} @if (show) {
<app-add-song [addedLive]="true" [showSongs]="showSongs" [show]="show" [songs]="songs$|async"></app-add-song>
} }
</app-card>
</div>
</div>
}

View File

@@ -1,25 +1,20 @@
@if (shows$ | async; as shows) {
<div @fade>
<div @fade>
@if (visible) {
<app-card heading="Bitte eine Veranstaltung auswählen">
@if (!shows.length) {
<p>
Es ist derzeit keine Veranstaltung vorhanden
</p>
}
@if (shows.length>0) {
<p>Es ist derzeit keine Veranstaltung vorhanden</p>
} @if (shows.length>0) {
<div class="list">
@for (show of shows; track show) {
@for (show of shows; track show.id) {
<button (click)="selectShow(show)" mat-stroked-button>
<app-user-name [userId]="show.owner"></app-user-name>
,
{{ show.showType | showType }},
{{ show.date.toDate() | date: "dd.MM.yyyy" }}
, {{ show.showType | showType }}, {{ show.date.toDate() | date: "dd.MM.yyyy" }}
</button>
}
</div>
}
</app-card>
}
</div>
</div>
}

View File

@@ -11,6 +11,7 @@ describe('SelectComponent', () => {
let showServiceSpy: jasmine.SpyObj<ShowService>;
let globalSettingsServiceSpy: jasmine.SpyObj<GlobalSettingsService>;
let routerSpy: jasmine.SpyObj<Router>;
const createShow = (id: string, isoDate: string) => ({id, date: {toDate: () => new Date(isoDate)}});
beforeEach(async () => {
showServiceSpy = jasmine.createSpyObj<ShowService>('ShowService', ['list$', 'update$']);
@@ -19,10 +20,10 @@ describe('SelectComponent', () => {
showServiceSpy.list$.and.returnValue(
of([
{id: 'older', date: {toDate: () => new Date('2025-12-15T00:00:00Z')}},
{id: 'recent-a', date: {toDate: () => new Date('2026-03-01T00:00:00Z')}},
{id: 'recent-b', date: {toDate: () => new Date('2026-02-20T00:00:00Z')}},
] as never)
createShow('older', '2025-12-15T00:00:00Z'),
createShow('recent-a', '2026-03-01T00:00:00Z'),
createShow('recent-b', '2026-02-20T00:00:00Z'),
]) as never
);
showServiceSpy.update$.and.resolveTo();
globalSettingsServiceSpy.set.and.resolveTo();

View File

@@ -3,7 +3,5 @@
</div>
<div mat-dialog-actions>
<button [mat-dialog-close]="false" mat-button>Abbrechen</button>
<button [mat-dialog-close]="true" cdkFocusInitial mat-button>
Archivieren
</button>
<button [mat-dialog-close]="true" cdkFocusInitial mat-button>Archivieren</button>
</div>

View File

@@ -0,0 +1,40 @@
<div mat-dialog-content>
<p>Bitte melde die in dieser Veranstaltung verwendeten CCLI-Titel. Die Meldung ist Teil der CCLI-Lizenz und sorgt dafür, dass Songwriter und Verlage korrekt vergütet werden.</p>
<p>
Die Meldung erfolgt über
<a [href]="reportingUrl" rel="noreferrer" target="_blank">{{ reportingUrl }}</a>.
</p>
<div class="song-list">
<div class="list-head">
<div>Titel</div>
<div>CCLI-Nummer</div>
</div>
@for (song of data.songs; track song.title + song.ccliNumber) {
<div class="list-item">
<div>{{ song.title }}</div>
<div class="number-cell">
<span>{{ song.ccliNumber }}</span>
<a
(click)="markOpened(song.ccliNumber)"
[attr.aria-label]="'CCLI-Titel melden: ' + song.title"
[href]="getSongReportingUrl(song.ccliNumber)"
rel="noreferrer"
target="_blank"
class="btn-icon report-link"
>
<fa-icon [icon]="faOpen"></fa-icon>
</a>
@if (wasOpened(song.ccliNumber)) {
<fa-icon [icon]="faCheck" class="opened-check"></fa-icon>
}
</div>
</div>
}
</div>
</div>
<div mat-dialog-actions>
<button [mat-dialog-close]="false" mat-button>Abbrechen</button>
<button [mat-dialog-close]="true" cdkFocusInitial mat-button>Alle CCLI-Titel wurden gemeldet</button>
</div>

View File

@@ -0,0 +1,74 @@
.song-list {
width: 100%;
}
.list-head,
.list-item {
display: grid;
grid-template-columns: auto 180px;
gap: 0;
align-items: center;
border-bottom: 1px solid var(--overlay)
}
.list-head {
padding: 3px 10px;
color: var(--text-muted);
font-size: 0.85rem;
font-weight: 600;
}
.list-item {
padding: 3px 10px;
transition: var(--transition);
&:not(:last-child) {
border-bottom: 1px solid var(--divider)
}
}
.list-head > div,
.list-item > div {
display: flex;
align-items: center;
}
.number-cell {
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.report-link {
display: inline-flex;
align-items: center;
color: inherit;
text-decoration: none;
}
.opened-check {
color: var(--success);
font-size: 0.85rem;
}
@media screen and (max-width: 640px) {
.list-head,
.list-item {
grid-template-columns: 1fr;
gap: 4px;
}
.list-head {
display: none;
}
.list-item {
padding: 10px 16px;
}
.number-cell {
justify-content: flex-start;
}
}

View File

@@ -0,0 +1,40 @@
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {MAT_DIALOG_DATA} from '@angular/material/dialog';
import {ReportDialogComponent} from './report-dialog.component';
describe('ReportDialogComponent', () => {
let component: ReportDialogComponent;
let fixture: ComponentFixture<ReportDialogComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ReportDialogComponent],
providers: [
{
provide: MAT_DIALOG_DATA,
useValue: {songs: [{title: 'Amazing Grace', ccliNumber: '12345'}]},
},
],
}).compileComponents();
fixture = TestBed.createComponent(ReportDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('create an instance', () => {
void expect(component).toBeTruthy();
});
it('should build direct reporting urls', () => {
expect(component.getSongReportingUrl('5770492')).toBe('https://reporting.ccli.com/search?s=5770492');
});
it('should mark numbers as opened locally', () => {
expect(component.wasOpened('12345')).toBeFalse();
component.markOpened('12345');
expect(component.wasOpened('12345')).toBeTrue();
});
});

View File

@@ -0,0 +1,41 @@
import {Component, inject} from '@angular/core';
import {MAT_DIALOG_DATA, MatDialogActions, MatDialogClose, MatDialogContent} from '@angular/material/dialog';
import {MatButton} from '@angular/material/button';
import {FaIconComponent} from '@fortawesome/angular-fontawesome';
import {faArrowUpRightFromSquare, faCheck} from '@fortawesome/free-solid-svg-icons';
export interface ReportDialogSong {
title: string;
ccliNumber: string;
}
export interface ReportDialogData {
songs: ReportDialogSong[];
}
@Component({
selector: 'app-report-dialog',
imports: [MatButton, MatDialogActions, MatDialogContent, MatDialogClose, FaIconComponent],
templateUrl: './report-dialog.component.html',
styleUrl: './report-dialog.component.less',
standalone: true,
})
export class ReportDialogComponent {
public readonly reportingUrl = 'https://reporting.ccli.com/search';
public readonly faOpen = faArrowUpRightFromSquare;
public readonly faCheck = faCheck;
public data = inject<ReportDialogData>(MAT_DIALOG_DATA);
private readonly openedNumbers = new Set<string>();
public getSongReportingUrl(ccliNumber: string): string {
return `${this.reportingUrl}?s=${encodeURIComponent(ccliNumber)}`;
}
public markOpened(ccliNumber: string): void {
this.openedNumbers.add(ccliNumber);
}
public wasOpened(ccliNumber: string): boolean {
return this.openedNumbers.has(ccliNumber);
}
}

View File

@@ -1,13 +1,8 @@
<div mat-dialog-content>
<a [href]="data.url">{{ data.url }}</a>
<div [style.background-image]="'url('+qrCode+')'" alt="qrcode" class="qrcode">
</div>
<div [style.background-image]="'url('+qrCode+')'" alt="qrcode" class="qrcode"></div>
</div>
<div mat-dialog-actions>
<button [mat-dialog-close]="true" cdkFocusInitial mat-button>
Schließen
</button>
<button (click)="share()" mat-button>
Teilen
</button>
<button [mat-dialog-close]="true" cdkFocusInitial mat-button>Schließen</button>
<button (click)="share()" mat-button>Teilen</button>
</div>

View File

@@ -25,7 +25,7 @@ export class ShareDialogComponent {
const data = this.data;
// eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment
QRCode.toDataURL(data.url, {
void QRCode.toDataURL(data.url, {
type: 'image/jpeg',
quality: 0.92,
width: 1280,

View File

@@ -6,18 +6,12 @@
<mat-select formControlName="showType">
<mat-optgroup label="öffentlich">
@for (key of showTypePublic; track key) {
<mat-option [value]="key">{{
key | showType
}}
</mat-option>
<mat-option [value]="key">{{ key | showType }} </mat-option>
}
</mat-optgroup>
<mat-optgroup label="privat">
@for (key of showTypePrivate; track key) {
<mat-option [value]="key">{{
key | showType
}}
</mat-option>
<mat-option [value]="key">{{ key | showType }} </mat-option>
}
</mat-optgroup>
</mat-select>

View File

@@ -12,17 +12,18 @@ describe('EditComponent', () => {
let showServiceSpy: jasmine.SpyObj<ShowService>;
let showDataServiceStub: Pick<ShowDataService, 'list$'>;
let routerSpy: jasmine.SpyObj<Router>;
const createDate = (isoDate: string) => ({toDate: () => new Date(isoDate)});
beforeEach(async () => {
showServiceSpy = jasmine.createSpyObj<ShowService>('ShowService', ['read$', 'update$']);
showDataServiceStub = {list$: of([] as never)};
showDataServiceStub = {list$: of([]) as ShowDataService['list$']};
routerSpy = jasmine.createSpyObj<Router>('Router', ['navigateByUrl']);
showServiceSpy.read$.and.returnValue(
of({
id: 'show-1',
showType: 'service-worship',
date: {toDate: () => new Date('2026-03-10T00:00:00Z')},
date: createDate('2026-03-10T00:00:00Z'),
} as never)
);
showServiceSpy.update$.and.resolveTo();

View File

@@ -7,7 +7,7 @@ import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/
import {ActivatedRoute, Router} from '@angular/router';
import {faSave} from '@fortawesome/free-solid-svg-icons';
import {map, switchMap} from 'rxjs/operators';
import {Timestamp} from '@angular/fire/firestore';
import {Timestamp} from 'firebase/firestore';
import {CardComponent} from '../../../widget-modules/components/card/card.component';
import {MatFormField, MatLabel, MatSuffix} from '@angular/material/form-field';
import {MatSelect} from '@angular/material/select';

View File

@@ -1,14 +1,10 @@
<div [formGroup]="filterFormGroup">
<div class="third">
<mat-form-field appearance="outline">
<mat-label>Zeitraum</mat-label>
<mat-select formControlName="time">
@for (time of times; track time) {
<mat-option [value]="time.key">{{
time.value
}}
</mat-option>
<mat-option [value]="time.key">{{ time.value }} </mat-option>
}
</mat-select>
</mat-form-field>
@@ -18,10 +14,7 @@
<mat-select formControlName="owner">
<mat-option value="">Alle</mat-option>
@for (owner of owners; track owner) {
<mat-option [value]="owner.key">{{
owner.value
}}
</mat-option>
<mat-option [value]="owner.key">{{ owner.value }} </mat-option>
}
</mat-select>
</mat-form-field>
@@ -32,23 +25,16 @@
<mat-option value="">Alle</mat-option>
<mat-optgroup label="öffentlich">
@for (key of showTypePublic; track key) {
<mat-option [value]="key">{{
key | showType
}}
</mat-option>
<mat-option [value]="key">{{ key | showType }} </mat-option>
}
</mat-optgroup>
<mat-optgroup label="privat">
@for (key of showTypePrivate; track key) {
<mat-option [value]="key">{{
key | showType
}}
</mat-option>
<mat-option [value]="key">{{ key | showType }} </mat-option>
}
</mat-optgroup>
</mat-select>
</mat-form-field>
</div>
<i>Anzahl der Suchergebnisse: {{ shows?.length ?? 0 }}</i>

View File

@@ -1,9 +1,14 @@
@if (show) {
<div class="list-item">
<div class="list-item">
<div>{{ show.date.toDate() | date: "dd.MM.yyyy" }}</div>
<div>
<app-user-name [userId]="show.owner"></app-user-name>
</div>
<div>{{ show.showType | showType }}</div>
<div>
@if (showStatusBadge) {
<app-badge [type]="showStatusBadgeType">{{ showStatusBadge }}</app-badge>
}
</div>
</div>
}

View File

@@ -1,7 +1,7 @@
.list-item {
padding: 5px 20px;
display: grid;
grid-template-columns: 100px 150px auto;
grid-template-columns: 100px 150px auto 160px;
min-height: 21px;
& > div {

View File

@@ -1,24 +1,45 @@
import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {BehaviorSubject, of} from 'rxjs';
import {ListItemComponent} from './list-item.component';
import {UserService} from '../../../../services/user/user.service';
describe('ListItemComponent', () => {
let component: ListItemComponent;
let fixture: ComponentFixture<ListItemComponent>;
beforeEach(waitForAsync(() => {
void TestBed.configureTestingModule({
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ListItemComponent],
providers: [
{
provide: UserService,
useValue: {
user$: new BehaviorSubject<unknown>({id: 'user-1'}).asObservable(),
userId$: new BehaviorSubject<string | null>('user-1').asObservable(),
loggedIn$: () => of(true),
getUserbyId$: () => of({name: 'Benjamin'}),
},
},
],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ListItemComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
void expect(component).toBeTruthy();
});
it('should render a status badge when provided', () => {
component.show = {date: {toDate: () => new Date('2026-03-15')} as never, owner: 'user-1', showType: 'misc-private'} as never;
component.showStatusBadge = 'nicht gemeldet';
component.showStatusBadgeType = 'error';
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('app-badge .badge');
expect(badge?.textContent?.trim()).toBe('nicht gemeldet');
expect(badge?.className).toContain('error');
});
});

View File

@@ -3,13 +3,16 @@ import {Show} from '../../services/show';
import {DatePipe} from '@angular/common';
import {UserNameComponent} from '../../../../services/user/user-name/user-name.component';
import {ShowTypePipe} from '../../../../widget-modules/pipes/show-type-translater/show-type.pipe';
import {BadgeComponent, BadgeType} from '../../../../widget-modules/components/badge/badge.component';
@Component({
selector: 'app-list-item',
templateUrl: './list-item.component.html',
styleUrls: ['./list-item.component.less'],
imports: [UserNameComponent, DatePipe, ShowTypePipe],
imports: [UserNameComponent, DatePipe, ShowTypePipe, BadgeComponent],
})
export class ListItemComponent {
@Input() public show: Show | null = null;
@Input() public showStatusBadge: string | null = null;
@Input() public showStatusBadgeType: BadgeType = 'none';
}

View File

@@ -7,36 +7,25 @@
</app-list-header>
<ng-container *appRole="['leader']">
@if (privateShows$ | async; as shows) {
@if (shows.length > 0) {
<app-card
[padding]="false"
heading="Meine Veranstaltungen"
>
@for (show of shows | sortBy: 'desc':'date'; track show) {
<app-list-item
[routerLink]="show.id"
[show]="show"
></app-list-item>
}
</app-card>
}
}
</ng-container>
@if (publicShows$ | async; as shows) {
@if (shows.length > 0) {
<app-card
[padding]="false"
heading="Veröffentlichte Veranstaltungen"
>
@if (privateShows$ | async; as shows) { @if (shows.length > 0) {
<app-card [padding]="false" heading="Meine Veranstaltungen">
@for (show of shows | sortBy: 'desc':'date'; track trackBy($index, show)) {
<app-list-item
[routerLink]="show.id"
[showStatusBadge]="show.published ? 'nicht gemeldet' : 'unveröffentlicht'"
[showStatusBadgeType]="show.published ? 'error' : 'none'"
[show]="show"
></app-list-item>
}
</app-card>
} }
</ng-container>
@if (publicShows$ | async; as shows) { @if (shows.length > 0) {
<app-card [padding]="false" heading="Veröffentlichte Veranstaltungen">
@for (show of shows | sortBy: 'desc':'date'; track trackBy($index, show)) {
<app-list-item [routerLink]="show.id" [show]="show"></app-list-item>
}
}
</app-card>
} }
</div>

View File

@@ -1,24 +1,80 @@
import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {BehaviorSubject, of} from 'rxjs';
import {ListComponent} from './list.component';
import {ShowService} from '../services/show.service';
import {UserService} from '../../../services/user/user.service';
import {FilterStoreService} from '../../../services/filter-store.service';
describe('ListComponent', () => {
let component: ListComponent;
let fixture: ComponentFixture<ListComponent>;
let shows$: BehaviorSubject<unknown[]>;
let user$: BehaviorSubject<unknown>;
const createShow = (overrides: Record<string, unknown>) => ({
archived: false,
date: {toDate: () => new Date('2026-03-01')},
...overrides,
});
beforeEach(waitForAsync(() => {
void TestBed.configureTestingModule({
beforeEach(async () => {
shows$ = new BehaviorSubject<unknown[]>([]);
user$ = new BehaviorSubject<unknown>({id: 'user-1'});
await TestBed.configureTestingModule({
imports: [ListComponent],
providers: [
{
provide: ShowService,
useValue: {
list$: () => shows$.asObservable(),
listPublicSince$: () => of([]),
},
},
{
provide: UserService,
useValue: {
user$: user$.asObservable(),
loggedIn$: () => of(true),
getUserbyId$: () => of({name: 'Benjamin'}),
},
},
FilterStoreService,
],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ListComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
void expect(component).toBeTruthy();
});
it('should list own drafts and pending published shows in my shows', done => {
shows$.next([
createShow({id: 'draft-own', owner: 'user-1', published: false, reportedType: null}),
createShow({id: 'pending-own', owner: 'user-1', published: true, reportedType: 'pending', date: {toDate: () => new Date('2026-03-02')}}),
createShow({id: 'reported-own', owner: 'user-1', published: true, reportedType: 'reported', date: {toDate: () => new Date('2026-03-03')}}),
createShow({id: 'draft-other', owner: 'user-2', published: false, reportedType: null, date: {toDate: () => new Date('2026-03-04')}}),
] as never);
component.privateShows$.subscribe(shows => {
expect(shows.map(show => show.id)).toEqual(['draft-own', 'pending-own']);
done();
});
});
it('should ignore show filters for my shows', done => {
const filterStore = TestBed.inject(FilterStoreService);
filterStore.updateShowFilter({time: 0, showType: 'service-worship'});
shows$.next([
createShow({id: 'older-draft', owner: 'user-1', published: false, reportedType: null, showType: 'misc-private', date: {toDate: () => new Date('2025-01-01')}}),
createShow({id: 'pending-own', owner: 'user-1', published: true, reportedType: 'pending', showType: 'home-group', date: {toDate: () => new Date('2026-03-05')}}),
] as never);
component.privateShows$.subscribe(shows => {
expect(shows.map(show => show.id)).toEqual(['older-draft', 'pending-own']);
done();
});
});
});

View File

@@ -14,6 +14,7 @@ import {FilterComponent} from './filter/filter.component';
import {CardComponent} from '../../../widget-modules/components/card/card.component';
import {ListItemComponent} from './list-item/list-item.component';
import {SortByPipe} from '../../../widget-modules/pipes/sort-by/sort-by.pipe';
import {UserService} from '../../../services/user/user.service';
@Component({
selector: 'app-list',
@@ -25,14 +26,15 @@ import {SortByPipe} from '../../../widget-modules/pipes/sort-by/sort-by.pipe';
export class ListComponent {
private showService = inject(ShowService);
private filterStore = inject(FilterStoreService);
private userService = inject(UserService);
public filter$ = this.filterStore.showFilter$;
public lastMonths$ = this.filter$.pipe(map((filterValues: FilterValues) => filterValues.time || 1));
public owner$ = this.filter$.pipe(map((filterValues: FilterValues) => filterValues.owner));
public showType$ = this.filter$.pipe(map((filterValues: FilterValues) => filterValues.showType));
public shows$ = this.showService.list$();
public privateShows$ = combineLatest([this.shows$, this.filter$]).pipe(
map(([shows, filter]) => shows.filter(show => !show.published).filter(show => this.matchesPrivateFilter(show, filter)))
public privateShows$ = combineLatest([this.shows$, this.userService.user$]).pipe(
map(([shows, user]) => shows.filter(show => show.owner === user?.id).filter(show => !show.published || show.reportedType === 'pending'))
);
public queriedPublicShows$ = this.lastMonths$.pipe(switchMap(lastMonths => this.showService.listPublicSince$(lastMonths)));
public fallbackPublicShows$ = combineLatest([this.shows$, this.lastMonths$]).pipe(
@@ -50,10 +52,6 @@ export class ListComponent {
public trackBy = (index: number, show: unknown) => (show as Show).id;
private matchesPrivateFilter(show: Show, filter: FilterValues): boolean {
return this.matchesTimeFilter(show, filter.time || 1) && (!filter.showType || show.showType === filter.showType);
}
private matchesTimeFilter(show: Show, lastMonths: number): boolean {
const startDate = new Date();
startDate.setHours(0, 0, 0, 0);

View File

@@ -6,18 +6,12 @@
<mat-select formControlName="showType">
<mat-optgroup label="öffentlich">
@for (key of showTypePublic; track key) {
<mat-option [value]="key">{{
key | showType
}}
</mat-option>
<mat-option [value]="key">{{ key | showType }} </mat-option>
}
</mat-optgroup>
<mat-optgroup label="privat">
@for (key of showTypePrivate; track key) {
<mat-option [value]="key">{{
key | showType
}}
</mat-option>
<mat-option [value]="key">{{ key | showType }} </mat-option>
}
</mat-optgroup>
</mat-select>

View File

@@ -21,8 +21,8 @@ describe('DocxService', () => {
it('should not try to save a document when the required data cannot be prepared', async () => {
const serviceInternals = service as DocxServiceInternals;
const prepareDataSpy = spyOn(serviceInternals, 'prepareData').and.resolveTo(null);
const saveAsSpy = spyOn(serviceInternals, 'saveAs');
const prepareDataSpy = spyOn<any>(serviceInternals, 'prepareData').and.resolveTo(null);
const saveAsSpy = spyOn<any>(serviceInternals, 'saveAs');
await service.create('show-1');
@@ -33,7 +33,7 @@ describe('DocxService', () => {
it('should build and save a docx file when all data is available', async () => {
const blob = new Blob(['docx']);
const serviceInternals = service as DocxServiceInternals;
const prepareDataSpy = spyOn(serviceInternals, 'prepareData').and.resolveTo({
const prepareDataSpy = spyOn<any>(serviceInternals, 'prepareData').and.resolveTo({
show: {
showType: 'service-worship',
date: {toDate: () => new Date('2026-03-10T00:00:00Z')},
@@ -42,8 +42,8 @@ describe('DocxService', () => {
user: {name: 'Benjamin'},
config: {ccliLicenseId: '12345'},
});
const prepareNewDocumentSpy = spyOn(serviceInternals, 'prepareNewDocument').and.returnValue({doc: true});
const saveAsSpy = spyOn(serviceInternals, 'saveAs');
const prepareNewDocumentSpy = spyOn<any>(serviceInternals, 'prepareNewDocument').and.returnValue({doc: true});
const saveAsSpy = spyOn<any>(serviceInternals, 'saveAs');
spyOn(Packer, 'toBlob').and.resolveTo(blob);
await service.create('show-1', {copyright: true});

View File

@@ -0,0 +1,60 @@
import {TestBed} from '@angular/core/testing';
import {of} from 'rxjs';
import {ShowDataService} from './show-data.service';
import {ShowSongDataService} from './show-song-data.service';
import {ShowSongIndexService} from './show-song-index.service';
import {UserSessionService} from '../../../services/user/user-session.service';
import {User} from '../../../services/user/user';
describe('ShowSongIndexService', () => {
let service: ShowSongIndexService;
let showDataServiceSpy: jasmine.SpyObj<ShowDataService>;
let showSongDataServiceSpy: jasmine.SpyObj<ShowSongDataService>;
let sessionSpy: jasmine.SpyObj<UserSessionService>;
beforeEach(async () => {
showDataServiceSpy = jasmine.createSpyObj<ShowDataService>('ShowDataService', ['listRaw$', 'update']);
showSongDataServiceSpy = jasmine.createSpyObj<ShowSongDataService>('ShowSongDataService', ['list$']);
sessionSpy = jasmine.createSpyObj<UserSessionService>('UserSessionService', ['update$'], {
user$: of({id: 'admin-1', name: 'Admin', role: 'admin', chordMode: 'onlyFirst', songUsage: {}} as User),
});
showDataServiceSpy.listRaw$.and.returnValue(of([{id: 'show-1'}, {id: 'show-2'}] as never) as unknown as ReturnType<ShowDataService['listRaw$']>);
showDataServiceSpy.update.and.resolveTo();
showSongDataServiceSpy.list$.and.callFake((showId: string) => {
if (showId === 'show-1') {
return of([{songId: 'song-1'}, {songId: 'song-2'}, {songId: 'song-1'}] as never) as never;
}
return of([{songId: 'song-3'}] as never) as never;
});
await TestBed.configureTestingModule({
providers: [
{provide: ShowDataService, useValue: showDataServiceSpy},
{provide: ShowSongDataService, useValue: showSongDataServiceSpy},
{provide: UserSessionService, useValue: sessionSpy},
],
});
service = TestBed.inject(ShowSongIndexService);
});
it('should rebuild the distinct songIds index for all shows', async () => {
await expectAsync(service.rebuildShowSongIds()).toBeResolvedTo({
showsProcessed: 2,
showSongsProcessed: 4,
});
expect(showDataServiceSpy.update).toHaveBeenCalledWith('show-1', {songIds: ['song-1', 'song-2']});
expect(showDataServiceSpy.update).toHaveBeenCalledWith('show-2', {songIds: ['song-3']});
});
it('should reject index rebuilds for non-admin users', async () => {
Object.defineProperty(sessionSpy, 'user$', {
value: of({id: 'user-1', name: 'User', role: 'leader', chordMode: 'onlyFirst', songUsage: {}} as User),
});
await expectAsync(service.rebuildShowSongIds()).toBeRejectedWithError('Admin role required to rebuild show song ids.');
});
});

View File

@@ -0,0 +1,65 @@
import {Injectable, inject} from '@angular/core';
import {firstValueFrom} from 'rxjs';
import {take} from 'rxjs/operators';
import {ShowDataService} from './show-data.service';
import {ShowSongDataService} from './show-song-data.service';
import {UserSessionService} from '../../../services/user/user-session.service';
export interface ShowSongIndexMigrationResult {
showsProcessed: number;
showSongsProcessed: number;
}
export interface MigrationProgress {
processed: number;
total: number;
showId: string;
showSongsProcessed: number;
}
@Injectable({
providedIn: 'root',
})
export class ShowSongIndexService {
private session = inject(UserSessionService);
private showDataService = inject(ShowDataService);
private showSongDataService = inject(ShowSongDataService);
public async rebuildShowSongIds(onProgress?: (progress: MigrationProgress) => void): Promise<ShowSongIndexMigrationResult> {
const currentUser = await firstValueFrom(this.session.user$.pipe(take(1)));
if (!currentUser || !this.hasAdminRole(currentUser.role)) {
throw new Error('Admin role required to rebuild show song ids.');
}
const shows = await firstValueFrom(this.showDataService.listRaw$());
let showSongsProcessed = 0;
let processed = 0;
for (const show of shows) {
const showSongs = await firstValueFrom(this.showSongDataService.list$(show.id));
const songIds = [...new Set(showSongs.map(showSong => showSong.songId).filter(Boolean))];
showSongsProcessed += showSongs.length;
await this.showDataService.update(show.id, {songIds});
processed += 1;
onProgress?.({
processed,
total: shows.length,
showId: show.id,
showSongsProcessed,
});
}
return {
showsProcessed: shows.length,
showSongsProcessed,
};
}
private hasAdminRole(role: string | null | undefined): boolean {
if (!role) {
return false;
}
return role.split(';').includes('admin');
}
}

View File

@@ -22,7 +22,7 @@ describe('ShowSongService', () => {
const show = {id: 'show-1', order: ['show-song-1', 'show-song-2']} as unknown as Show;
beforeEach(async () => {
user$ = new BehaviorSubject<User | null>({id: 'user-1', name: 'Benjamin', role: 'editor', chordMode: 'letters', songUsage: {}});
user$ = new BehaviorSubject<User | null>({id: 'user-1', name: 'Benjamin', role: 'editor', chordMode: 'onlyFirst', songUsage: {}});
showSongDataServiceSpy = jasmine.createSpyObj<ShowSongDataService>('ShowSongDataService', ['add', 'read$', 'list$', 'delete', 'update$']);
songDataServiceSpy = jasmine.createSpyObj<SongDataService>('SongDataService', ['read$']);
userServiceSpy = jasmine.createSpyObj<UserService>('UserService', ['incSongCount', 'decSongCount'], {
@@ -66,7 +66,7 @@ describe('ShowSongService', () => {
songId: 'song-1',
key: 'G',
keyOriginal: 'G',
chordMode: 'letters',
chordMode: 'onlyFirst',
addedLive: true,
});
});
@@ -103,7 +103,7 @@ describe('ShowSongService', () => {
await service.delete$('show-1', 'show-song-1', 0);
expect(showSongDataServiceSpy.delete).toHaveBeenCalledWith('show-1', 'show-song-1');
expect(showServiceSpy.update$).toHaveBeenCalledWith('show-1', {order: ['show-song-2']});
expect(showServiceSpy.update$).toHaveBeenCalledWith('show-1', jasmine.objectContaining({order: ['show-song-2']}));
expect(userServiceSpy.decSongCount).toHaveBeenCalledWith('song-1');
});

View File

@@ -5,6 +5,7 @@ import {ShowSong} from './show-song';
import {SongDataService} from '../../songs/services/song-data.service';
import {UserService} from '../../../services/user/user.service';
import {ShowService} from './show.service';
import {arrayRemove, arrayUnion} from 'firebase/firestore';
@Injectable({
providedIn: 'root',
@@ -27,8 +28,9 @@ export class ShowSongService {
chordMode: user.chordMode,
addedLive,
};
await this.userService.incSongCount(songId);
return await this.showSongDataService.add(showId, data);
const showSongId = await this.showSongDataService.add(showId, data);
await Promise.all([this.userService.incSongCount(songId), this.showService.update$(showId, {songIds: arrayUnion(songId) as never})]);
return showSongId;
}
public read$ = (showId: string, songId: string): Observable<ShowSong | null> => this.showSongDataService.read$(showId, songId);
@@ -38,14 +40,19 @@ export class ShowSongService {
public list = (showId: string): Promise<ShowSong[]> => firstValueFrom(this.list$(showId));
public async delete$(showId: string, showSongId: string, index: number): Promise<void> {
const [showSong, show] = await Promise.all([this.read(showId, showSongId), firstValueFrom(this.showService.read$(showId))]);
const [showSong, show, showSongs] = await Promise.all([this.read(showId, showSongId), firstValueFrom(this.showService.read$(showId)), this.list(showId)]);
if (!show) return;
if (!showSong) return;
const order = [...show.order];
order.splice(index, 1);
const hasSameSongStillInShow = showSongs.some(song => song.id !== showSongId && song.songId === showSong.songId);
await Promise.all([this.showSongDataService.delete(showId, showSongId), this.showService.update$(showId, {order}), this.userService.decSongCount(showSong.songId)]);
await Promise.all([
this.showSongDataService.delete(showId, showSongId),
this.showService.update$(showId, hasSameSongStillInShow ? {order} : {order, songIds: arrayRemove(showSong.songId) as never}),
this.userService.decSongCount(showSong.songId),
]);
}
public update$ = async (showId: string, songId: string, data: Partial<ShowSong>): Promise<void> => await this.showSongDataService.update$(showId, songId, data);

View File

@@ -17,7 +17,7 @@ describe('ShowService', () => {
beforeEach(async () => {
user$ = new BehaviorSubject<unknown>({id: 'user-1'});
showDataServiceSpy = jasmine.createSpyObj<ShowDataService>('ShowDataService', ['read$', 'listPublicSince$', 'update', 'add'], {
list$: of(shows),
list$: of(shows) as unknown as ShowDataService['list$'],
});
showDataServiceSpy.read$.and.returnValue(of(shows[0]));
showDataServiceSpy.listPublicSince$.and.returnValue(of([shows[1]]));
@@ -97,6 +97,7 @@ describe('ShowService', () => {
showType: type,
owner: 'user-1',
order: [],
songIds: [],
public: true,
});
});
@@ -111,6 +112,7 @@ describe('ShowService', () => {
showType: type,
owner: 'user-1',
order: [],
songIds: [],
public: false,
});
});

View File

@@ -39,6 +39,7 @@ export class ShowService {
...data,
owner: user.id,
order: [],
songIds: [],
public: ShowService.SHOW_TYPE_PUBLIC.indexOf(data.showType) !== -1,
};
return await this.showDataService.add(calculatedData);

View File

@@ -1,14 +1,16 @@
import {Timestamp} from '@angular/fire/firestore';
export type PresentationBackground = 'none' | 'blue' | 'green' | 'leder' | 'praise' | 'bible';
export type ReportedType = null | 'pending' | 'reported' | 'not-required';
export interface Show {
id: string;
showType: string;
date: Timestamp;
owner: string;
songIds?: string[];
public: boolean;
reported: boolean;
reportedType: ReportedType;
published: boolean;
archived: boolean;
order: string[];

View File

@@ -1,5 +1,5 @@
@if (show$ | async; as show) {
<div>
<div>
<app-card
[fullscreen]="useSwiper"
closeLink="../"
@@ -8,8 +8,15 @@
}} - {{ getStatus(show) }}"
>
@if (!useSwiper) {
<p>{{ show.public ? 'öffentliche' : 'geschlossene' }} Veranstaltung von
<p class="show-meta">
{{ show.public ? 'öffentliche' : 'geschlossene' }} Veranstaltung von
<app-user-name [userId]="show.owner"></app-user-name>
<ng-container *appOwner="show.owner">
<app-badge [type]="getPublishedBadgeType(show)">{{ show.published | publishedType }}</app-badge>
@if (show.reportedType) {
<app-badge [type]="getReportedTypeBadgeType(show)">{{ show.reportedType | reportedType }}</app-badge>
}
</ng-container>
</p>
}
<div class="head">
@@ -19,22 +26,26 @@
}
</div>
<div [class.floating]="useSwiper">
<app-menu-button (click)="onZoomOut()" @fade [icon]="faZoomOut" class="btn-delete btn-icon"
matTooltip="Verkleinern"></app-menu-button>
<app-menu-button (click)="onZoomIn()" @fade [icon]="faZoomIn" class="btn-delete btn-icon"
matTooltip="Vergrößern"></app-menu-button>
<app-menu-button (click)="useSwiper=!useSwiper;fullscreen(useSwiper)" @fade
[icon]="useSwiper ? faRestore : faMaximize" class="btn-delete btn-icon"
matTooltip="Vollbild"></app-menu-button>
<app-menu-button (click)="onZoomOut()" @fade [icon]="faZoomOut" class="btn-delete btn-icon" matTooltip="Verkleinern"></app-menu-button>
<app-menu-button (click)="onZoomIn()" @fade [icon]="faZoomIn" class="btn-delete btn-icon" matTooltip="Vergrößern"></app-menu-button>
<app-menu-button
(click)="useSwiper=!useSwiper;fullscreen(useSwiper)"
@fade
[icon]="useSwiper ? faRestore : faMaximize"
class="btn-delete btn-icon"
matTooltip="Vollbild"
></app-menu-button>
</div>
</div>
@if (showSongs && !useSwiper) {
<div (cdkDropListDropped)="drop($event, show)"
<div
(cdkDropListDropped)="drop($event, show)"
[cdkDropListDisabled]="show.published || showText"
[style.--song-key-column-width]="getSongKeyColumnWidth(show)"
[style.font-size]="textSize + 'em'"
cdkDropList
class="song-list">
class="song-list"
>
@for (song of orderedShowSongs(show); track trackBy(i, song); let i = $index) {
<div cdkDrag class="song-row">
<app-song
@@ -49,89 +60,51 @@
</div>
}
</div>
}
@if (useSwiper) {
} @if (useSwiper) {
<swiper-container scrollbar="true">
@for (song of orderedShowSongs(show); track trackBy(i, song); let i = $index) {
<swiper-slide
[style.font-size]="textSize + 'em'"
class="song-swipe">
<app-song
[fullscreen]="true"
[index]="i"
[showId]="showId"
[showSong]="song"
[showText]="true"
[show]="show"
></app-song>
<swiper-slide [style.font-size]="textSize + 'em'" class="song-swipe">
<app-song [fullscreen]="true" [index]="i" [showId]="showId" [showSong]="song" [showText]="true" [show]="show"></app-song>
<div class="time">{{ currentTime | date: 'HH:mm' }}</div>
@if (getNextSong(orderedShowSongs(show), i); as next) {
<div class="next-song">{{ next }}
<div class="next-song">
{{ next }}
<fa-icon [icon]="faNextSong"></fa-icon>
</div>
}
</swiper-slide>
}
</swiper-container>
}
@if (songs$ | async; as songs) {
@if (songs && !show.published && !useSwiper) {
<app-add-song
[showSongs]="showSongs"
[show]="show"
[songs]="songs"
></app-add-song>
}
}
@if (!useSwiper) {
} @if (songs$ | async; as songs) { @if (songs && !show.published && !useSwiper) {
<app-add-song [showSongs]="showSongs" [show]="show" [songs]="songs"></app-add-song>
} } @if (!useSwiper) {
<app-button-row>
<ng-container *appRole="['leader']">
<ng-container *appOwner="show.owner">
@if (!show.archived) {
<app-button (click)="onArchive(true)" [icon]="faBox">
Archivieren
</app-button>
}
@if (show.archived) {
<app-button (click)="onArchive(false)" [icon]="faBoxOpen">
Wiederherstellen
</app-button>
}
@if (!show.published) {
<app-button (click)="onPublish(true)" [icon]="faPublish">
Veröffentlichen
</app-button>
}
@if (show.published) {
<app-button (click)="onPublish(false)" [icon]="faUnpublish">
Veröffentlichung zurückziehen
</app-button>
}
@if (show.published) {
<app-button (click)="onShare(show)" [icon]="faShare">
Teilen
</app-button>
}
@if (!show.published) {
<app-button (click)="onChange(show.id)" [icon]="faSliders">
Ändern
</app-button>
<app-button (click)="onArchive(true)" [icon]="faBox"> Archivieren </app-button>
} @if (show.archived) {
<app-button (click)="onArchive(false)" [icon]="faBoxOpen"> Wiederherstellen </app-button>
} @if (!show.published) {
<app-button (click)="onPublish(show, true)" [icon]="faPublish"> Veröffentlichen </app-button>
} @if (show.published) {
<app-button (click)="onPublish(show, false)" [icon]="faUnpublish"> Veröffentlichung zurückziehen </app-button>
} @if (show.published) {
<app-button (click)="onShare(show)" [icon]="faShare"> Teilen </app-button>
} @if (show.published && show.reportedType === 'pending') {
<app-button (click)="onReport(show)" [icon]="faReport"> Melden </app-button>
} @if (!show.published) {
<app-button (click)="onChange(show.id)" [icon]="faSliders"> Ändern </app-button>
}
</ng-container>
</ng-container>
<app-button [icon]="faDownload" [matMenuTriggerFor]="menu">
Herunterladen
</app-button>
<app-button [icon]="faDownload" [matMenuTriggerFor]="menu"> Herunterladen </app-button>
<mat-menu #menu="matMenu">
<app-button (click)="onDownload()" [icon]="faUser">
Ablauf für Lobpreisgruppe
</app-button>
<app-button (click)="onDownloadHandout()" [icon]="faUsers">
Handout mit Copyright Infos
</app-button>
<app-button (click)="onDownload()" [icon]="faUser"> Ablauf für Lobpreisgruppe </app-button>
<app-button (click)="onDownloadHandout()" [icon]="faUsers"> Handout mit Copyright Infos </app-button>
</mat-menu>
</app-button-row>
}
</app-card>
</div>
</div>
}

View File

@@ -13,6 +13,13 @@
min-height: calc(100vh - 60px);
}
.show-meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.head {
display: flex;

View File

@@ -1,24 +1,114 @@
import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {BehaviorSubject, of} from 'rxjs';
import {ShowComponent} from './show.component';
import {ActivatedRoute, Router} from '@angular/router';
import {ShowService} from '../services/show.service';
import {SongService} from '../../songs/services/song.service';
import {ShowSongService} from '../services/show-song.service';
import {DocxService} from '../services/docx.service';
import {UserService} from '../../../services/user/user.service';
import {MatDialog} from '@angular/material/dialog';
import {GuestShowService} from '../../guest/guest-show.service';
describe('ShowComponent', () => {
let component: ShowComponent;
let fixture: ComponentFixture<ShowComponent>;
let showServiceSpy: jasmine.SpyObj<ShowService>;
let showSongServiceSpy: jasmine.SpyObj<ShowSongService>;
let dialogSpy: jasmine.SpyObj<MatDialog>;
let user$: BehaviorSubject<unknown>;
let userId$: BehaviorSubject<string | null>;
beforeEach(waitForAsync(() => {
void TestBed.configureTestingModule({
beforeEach(async () => {
showServiceSpy = jasmine.createSpyObj<ShowService>('ShowService', ['update$', 'read$']);
showSongServiceSpy = jasmine.createSpyObj<ShowSongService>('ShowSongService', ['list$', 'list']);
dialogSpy = jasmine.createSpyObj<MatDialog>('MatDialog', ['open']);
user$ = new BehaviorSubject<unknown>({id: 'user-1', role: ['leader']});
userId$ = new BehaviorSubject<string | null>('user-1');
showServiceSpy.read$.and.returnValue(of(null));
showServiceSpy.update$.and.resolveTo();
showSongServiceSpy.list$.and.returnValue(of([]));
showSongServiceSpy.list.and.resolveTo([]);
await TestBed.configureTestingModule({
imports: [ShowComponent],
providers: [
{provide: ActivatedRoute, useValue: {params: of({showId: 'show-1'})}},
{provide: ShowService, useValue: showServiceSpy},
{provide: SongService, useValue: {list$: () => of([])}},
{provide: ShowSongService, useValue: showSongServiceSpy},
{provide: DocxService, useValue: {create: jasmine.createSpy('create').and.resolveTo()}},
{provide: Router, useValue: {navigateByUrl: jasmine.createSpy('navigateByUrl')}},
{
provide: UserService,
useValue: {
user$: user$.asObservable(),
userId$: userId$.asObservable(),
loggedIn$: () => of(true),
},
},
{provide: MatDialog, useValue: dialogSpy},
{provide: GuestShowService, useValue: {share: jasmine.createSpy('share').and.resolveTo('https://example.invalid')}},
],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ShowComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
void expect(component).toBeTruthy();
});
it('should reset reportedType when unpublishing', async () => {
await component.onPublish({id: 'show-1', public: true} as never, false);
expect(showServiceSpy.update$).toHaveBeenCalledWith('show-1', {published: false, reportedType: null});
});
it('should set not-required for private shows when publishing', async () => {
await component.onPublish({id: 'show-1', public: false} as never, true);
expect(showServiceSpy.update$).toHaveBeenCalledWith('show-1', {published: true, reportedType: 'not-required'});
});
it('should set pending for public shows with reportable CCLI songs', async () => {
showSongServiceSpy.list.and.resolveTo([{legalOwner: 'CCLI', legalOwnerId: '123'}] as never);
await component.onPublish({id: 'show-1', public: true} as never, true);
expect(showServiceSpy.update$).toHaveBeenCalledWith('show-1', {published: true, reportedType: 'pending'});
});
it('should set not-required for public shows without reportable CCLI songs', async () => {
showSongServiceSpy.list.and.resolveTo([{legalOwner: 'CCLI', legalOwnerId: ''}] as never);
await component.onPublish({id: 'show-1', public: true} as never, true);
expect(showServiceSpy.update$).toHaveBeenCalledWith('show-1', {published: true, reportedType: 'not-required'});
});
it('should open report dialog with deduplicated reportable songs and mark show as reported', () => {
component.showSongs = [
{id: 'show-song-1', songId: 'song-1', title: 'Alpha', legalOwner: 'CCLI', legalOwnerId: '123'},
{id: 'show-song-2', songId: 'song-1', title: 'Alpha', legalOwner: 'CCLI', legalOwnerId: '123'},
{id: 'show-song-3', songId: 'song-2', title: 'Beta', legalOwner: 'other', legalOwnerId: '456'},
{id: 'show-song-4', songId: 'song-3', title: 'Gamma', legalOwner: 'CCLI', legalOwnerId: '789'},
] as never;
dialogSpy.open.and.returnValue({afterClosed: () => of(true)} as never);
component.onReport({id: 'show-1', order: ['show-song-1', 'show-song-2', 'show-song-3', 'show-song-4']} as never);
expect(dialogSpy.open).toHaveBeenCalledWith(jasmine.any(Function), {
width: '640px',
data: {
songs: [
{title: 'Alpha', ccliNumber: '123'},
{title: 'Gamma', ccliNumber: '789'},
],
},
});
expect(showServiceSpy.update$).toHaveBeenCalledWith('show-1', {reportedType: 'reported'});
});
});

View File

@@ -14,6 +14,7 @@ import {
faArrowUpRightFromSquare,
faBox,
faBoxOpen,
faCheck,
faChevronRight,
faFileDownload,
faLock,
@@ -49,6 +50,11 @@ import {OwnerDirective} from '../../../services/user/owner.directive';
import {ButtonComponent} from '../../../widget-modules/components/button/button.component';
import {MatMenu, MatMenuTrigger} from '@angular/material/menu';
import {ShowTypePipe} from '../../../widget-modules/pipes/show-type-translater/show-type.pipe';
import {UserService} from '../../../services/user/user.service';
import {ReportedTypePipe} from '../../../widget-modules/pipes/reported-type-translator/reported-type.pipe';
import {BadgeComponent, BadgeType} from '../../../widget-modules/components/badge/badge.component';
import {ReportDialogComponent, ReportDialogSong} from '../dialog/report-dialog/report-dialog.component';
import {PublishedTypePipe} from '../../../widget-modules/pipes/published-type-translator/published-type.pipe';
@Component({
selector: 'app-show',
@@ -78,6 +84,9 @@ import {ShowTypePipe} from '../../../widget-modules/pipes/show-type-translater/s
AsyncPipe,
DatePipe,
ShowTypePipe,
ReportedTypePipe,
PublishedTypePipe,
BadgeComponent,
],
})
export class ShowComponent implements OnInit, OnDestroy {
@@ -88,6 +97,7 @@ export class ShowComponent implements OnInit, OnDestroy {
private docxService = inject(DocxService);
private router = inject(Router);
private cRef = inject(ChangeDetectorRef);
private userService = inject(UserService);
public dialog = inject(MatDialog);
private guestShowService = inject(GuestShowService);
@@ -99,6 +109,7 @@ export class ShowComponent implements OnInit, OnDestroy {
public faBox = faBox;
public faBoxOpen = faBoxOpen;
public faReport = faCheck;
public faPublish = faUnlock;
public faUnpublish = faLock;
public faShare = faArrowUpRightFromSquare;
@@ -171,20 +182,39 @@ export class ShowComponent implements OnInit, OnDestroy {
}
public onArchive(archived: boolean): void {
if (!archived && this.showId != null) void this.showService.update$(this.showId, {archived});
if (!archived && this.showId != null) void this.setArchiveState(false);
else {
const dialogRef = this.dialog.open(ArchiveDialogComponent, {
width: '350px',
});
dialogRef.afterClosed().pipe(take(1)).subscribe((archive: boolean) => {
if (archive && this.showId != null) void this.showService.update$(this.showId, {archived});
dialogRef
.afterClosed()
.pipe(take(1))
.subscribe((archive: boolean) => {
if (archive && this.showId != null) void this.setArchiveState(true);
});
}
}
public async onPublish(published: boolean): Promise<void> {
if (this.showId != null) await this.showService.update$(this.showId, {published});
public async onPublish(show: Show, published: boolean): Promise<void> {
if (!show.id) {
return;
}
if (!published) {
await this.showService.update$(show.id, {published: false, reportedType: null});
return;
}
if (!show.public) {
await this.showService.update$(show.id, {published: true, reportedType: 'not-required'});
return;
}
const showSongs = this.showSongs ?? (await this.showSongService.list(show.id));
const reportedType = showSongs.some(song => song.legalOwner === 'CCLI' && !!song.legalOwnerId) ? 'pending' : 'not-required';
await this.showService.update$(show.id, {published: true, reportedType});
}
public onShare = async (show: Show): Promise<void> => {
@@ -192,16 +222,72 @@ export class ShowComponent implements OnInit, OnDestroy {
this.dialog.open(ShareDialogComponent, {data: {url, show}});
};
public onReport(show: Show): void {
const songs = this.getReportableSongs(show);
if (songs.length === 0) {
return;
}
const dialogRef = this.dialog.open(ReportDialogComponent, {
width: '640px',
data: {songs},
});
dialogRef
.afterClosed()
.pipe(take(1))
.subscribe((reported: boolean) => {
if (reported) {
void this.showService.update$(show.id, {reportedType: 'reported'});
}
});
}
public getStatus(show: Show): string {
if (show.published) {
return 'veröffentlicht';
}
if (show.reported) {
if (show.reportedType === 'reported') {
return 'gemeldet';
}
return 'entwurf';
}
public getReportedTypeBadgeType(show: Show): BadgeType {
switch (show.reportedType) {
case 'pending':
return 'error';
case 'reported':
return 'ok';
case 'not-required':
return 'none';
default:
return 'none';
}
}
public getPublishedBadgeType(show: Show): BadgeType {
return show.published ? 'ok' : 'none';
}
private getReportableSongs(show: Show): ReportDialogSong[] {
const uniqueSongs = new Map<string, ReportDialogSong>();
this.orderedShowSongs(show)
.filter(song => song.legalOwner === 'CCLI' && !!song.legalOwnerId)
.forEach(song => {
const key = song.songId || `${song.title}:${song.legalOwnerId}`;
if (!uniqueSongs.has(key)) {
uniqueSongs.set(key, {
title: song.title,
ccliNumber: song.legalOwnerId,
});
}
});
return Array.from(uniqueSongs.values());
}
public async onDownload(): Promise<void> {
if (this.showId != null) await this.docxService.create(this.showId);
}
@@ -274,6 +360,20 @@ export class ShowComponent implements OnInit, OnDestroy {
const widthInCh = Math.max(3, longestLabelLength);
return `${widthInCh}ch`;
}
private async setArchiveState(archived: boolean): Promise<void> {
if (!this.showId) {
return;
}
const updates: Array<Promise<void | null>> = [this.showService.update$(this.showId, {archived})];
(this.showSongs ?? []).forEach(showSong => {
updates.push(archived ? this.userService.decSongCount(showSong.songId) : this.userService.incSongCount(showSong.songId));
});
await Promise.all(updates);
}
}
export interface Swiper {

View File

@@ -1,25 +1,14 @@
@if (iSong && iSong && show) {
<div>
<div>
@if (show.published || fullscreen) {
<div class="title published">
<div class="key">{{ iSong.key }}</div>
<div>{{ iSong.title }}</div>
</div>
}
@if (!show.published && !fullscreen) {
<div
class="song"
[class.show-text-layout]="!!showText"
[class.compact-layout]="!showText"
[class.with-drag]="dragHandle && !edit"
>
} @if (!show.published && !fullscreen) {
<div class="song" [class.show-text-layout]="!!showText" [class.compact-layout]="!showText" [class.with-drag]="dragHandle && !edit">
@if (dragHandle && !edit) {
<button
aria-label="Lied verschieben"
cdkDragHandle
class="drag-handle"
type="button"
></button>
<button aria-label="Lied verschieben" cdkDragHandle class="drag-handle" type="button"></button>
}
<span class="title">{{ iSong.title }}</span>
@if (!edit) {
@@ -31,28 +20,17 @@
<span>{{ iSong.key }}</span>
</div>
</div>
}
@if (!edit) {
<app-menu-button (click)="onEdit()" [icon]="faEdit" class="btn-edit btn-icon"
matTooltip="Lied für diese Veranstaltung bearbeiten"></app-menu-button>
}
@if (!edit) {
<app-menu-button (click)="onDelete()" [icon]="faDelete" class="btn-delete btn-icon"
matTooltip="Lied aus Veranstaltung entfernen"></app-menu-button>
} @if (!edit) {
<app-menu-button (click)="onEdit()" [icon]="faEdit" class="btn-edit btn-icon" matTooltip="Lied für diese Veranstaltung bearbeiten"></app-menu-button>
} @if (!edit) {
<app-menu-button (click)="onDelete()" [icon]="faDelete" class="btn-delete btn-icon" matTooltip="Lied aus Veranstaltung entfernen"></app-menu-button>
}
</div>
@if (!edit) {
<div
aria-hidden="true"
class="song select"
[class.show-text-layout]="!!showText"
[class.compact-layout]="!showText"
[class.with-drag]="dragHandle"
>
<div aria-hidden="true" class="song select" [class.show-text-layout]="!!showText" [class.compact-layout]="!showText" [class.with-drag]="dragHandle">
@if (dragHandle) {
<span class="drag-handle-placeholder"></span>
}
@if (!showText) {
} @if (!showText) {
<span class="keys">
<mat-form-field class="keys-select">
<mat-select #option [formControl]="keyFormControl" tabIndex="-1">
@@ -78,29 +56,19 @@
<span class="btn-edit"></span>
<span class="btn-delete"></span>
</div>
}
}
@if (edit) {
} } @if (edit) {
<mat-form-field appearance="outline">
<mat-label>Songtext</mat-label>
<textarea [cdkTextareaAutosize]="true"
[formControl]="editSongControl"
class="edit"
matInput
matTooltip="Tonart ändern"
></textarea>
<textarea [cdkTextareaAutosize]="true" [formControl]="editSongControl" class="edit" matInput matTooltip="Tonart ändern"></textarea>
</mat-form-field>
}
@if (edit) {
} @if (edit) {
<div>Es wird nur der Liedtext für dieser Veranstaltung geändert.</div>
}
@if (edit) {
} @if (edit) {
<app-button-row>
<app-button (click)="onSave()" [icon]="faSave">Speichern</app-button>
<app-button (click)="onDiscard()" [icon]="faEraser">Verwerfen</app-button>
</app-button-row>
}
@if (!edit && (showText)) {
} @if (!edit && (showText)) {
<app-song-text
(chordModeChanged)="onChordModeChanged($event)"
[chordMode]="iSong.chordMode"
@@ -109,5 +77,5 @@
[transpose]="{ baseKey: iSong.keyOriginal, targetKey: iSong.key }"
></app-song-text>
}
</div>
</div>
}

View File

@@ -30,7 +30,7 @@ describe('FileService', () => {
});
it('should resolve download urls via AngularFire storage helpers', async () => {
const resolveSpy = spyOn(service as FileServiceInternals, 'resolveDownloadUrl').and.resolveTo('https://cdn.example/file.pdf');
const resolveSpy = spyOn<any>(service as FileServiceInternals, 'resolveDownloadUrl').and.resolveTo('https://cdn.example/file.pdf');
await expectAsync(service.getDownloadUrl('songs/song-1/file.pdf').toPromise()).toBeResolvedTo('https://cdn.example/file.pdf');
@@ -38,7 +38,7 @@ describe('FileService', () => {
});
it('should delete the file from storage and metadata from firestore', async () => {
const deleteFromStorageSpy = spyOn(service as FileServiceInternals, 'deleteFromStorage').and.resolveTo();
const deleteFromStorageSpy = spyOn<any>(service as FileServiceInternals, 'deleteFromStorage').and.resolveTo();
await service.delete('songs/song-1/file.pdf', 'song-1', 'file-1');

View File

@@ -8,8 +8,8 @@ describe('SongListResolver', () => {
let songServiceSpy: jasmine.SpyObj<SongService>;
beforeEach(async () => {
songServiceSpy = jasmine.createSpyObj<SongService>('SongService', ['list$']);
songServiceSpy.list$.and.returnValue(of([{id: 'song-1', title: 'Amazing Grace'}] as never));
songServiceSpy = jasmine.createSpyObj<SongService>('SongService', ['listLoaded$']);
songServiceSpy.listLoaded$.and.returnValue(of([{id: 'song-1', title: 'Amazing Grace'}]) as never);
await TestBed.configureTestingModule({
providers: [{provide: SongService, useValue: songServiceSpy}],
@@ -24,7 +24,7 @@ describe('SongListResolver', () => {
it('should resolve the first emitted song list from the service', done => {
resolver.resolve().subscribe(songs => {
expect(songServiceSpy.list$).toHaveBeenCalled();
expect(songServiceSpy.listLoaded$).toHaveBeenCalled();
expect(songs).toEqual([{id: 'song-1', title: 'Amazing Grace'}] as never);
done();
});

View File

@@ -3,7 +3,7 @@ import {firstValueFrom, Observable} from 'rxjs';
import {Song} from './song';
import {SongDataService} from './song-data.service';
import {UserService} from '../../../services/user/user.service';
import {Timestamp} from '@angular/fire/firestore';
import {Timestamp} from 'firebase/firestore';
// declare let importCCLI: any;

View File

@@ -195,15 +195,15 @@ Text`;
void expect(sections[1].lines[2].type).toBe(LineType.chord);
void expect(sections[1].lines[2].text).toBe(' a d e f g a h c b');
void expect(sections[2].lines[0].type).toBe(LineType.chord);
void expect(sections[2].lines[0].text).toBe('c c d c7 cmaj7 c/e');
void expect(sections[2].lines[0].text).toBe('c c# db c7 cmaj7 c/e');
void expect(sections[2].lines[0].chords).toEqual([
{chord: 'c', length: 1, position: 0, add: null, slashChord: null, addDescriptor: null},
{chord: 'c#', length: 2, position: 2, add: null, slashChord: null, addDescriptor: null},
{chord: 'db', length: 2, position: 5, add: null, slashChord: null, addDescriptor: null},
{chord: 'c', length: 2, position: 8, add: '7', slashChord: null, addDescriptor: descriptor('7', {extensions: ['7']})},
{chord: 'c', length: 5, position: 13, add: 'maj7', slashChord: null, addDescriptor: descriptor('maj7', {quality: 'major', extensions: ['7']})},
{chord: 'c', length: 3, position: 22, add: null, slashChord: 'e', addDescriptor: null},
jasmine.objectContaining({chord: 'c', length: 1, position: 0, add: null, slashChord: null, addDescriptor: null}),
jasmine.objectContaining({chord: 'c#', length: 2, position: 2, add: null, slashChord: null, addDescriptor: null}),
jasmine.objectContaining({chord: 'db', length: 2, position: 5, add: null, slashChord: null, addDescriptor: null}),
jasmine.objectContaining({chord: 'c', length: 2, position: 8, add: '7', slashChord: null, addDescriptor: descriptor('7', {extensions: ['7']})}),
jasmine.objectContaining({chord: 'c', length: 5, position: 13, add: 'maj7', slashChord: null, addDescriptor: descriptor('maj7', {quality: 'major', extensions: ['7']})}),
jasmine.objectContaining({chord: 'c', length: 3, position: 22, add: null, slashChord: 'e', addDescriptor: null}),
]);
});
@@ -228,9 +228,9 @@ Text`;
const sections = service.parse(text, null);
void expect(sections[0].lines[0].chords).toEqual([
{chord: 'C', length: 1, position: 0, add: null, slashChord: null, addDescriptor: null},
{chord: 'G', length: 3, position: 8, add: null, slashChord: 'B', addDescriptor: null},
{chord: 'A', length: 2, position: 17, add: 'm', slashChord: null, addDescriptor: descriptor('m', {quality: 'minor'})},
jasmine.objectContaining({chord: 'C', length: 1, position: 0, add: null, slashChord: null, addDescriptor: null}),
jasmine.objectContaining({chord: 'G', length: 3, position: 8, add: null, slashChord: 'B', addDescriptor: null}),
jasmine.objectContaining({chord: 'A', length: 2, position: 17, add: 'm', slashChord: null, addDescriptor: descriptor('m', {quality: 'minor'})}),
]);
});
@@ -244,11 +244,11 @@ Text`;
void expect(sections[0].lines[0].type).toBe(LineType.chord);
void expect(sections[0].lines[0].chords).toEqual([
{chord: 'C', length: 5, position: 0, add: 'maj7', slashChord: null, addDescriptor: descriptor('maj7', {quality: 'major', extensions: ['7']})},
{chord: 'D', length: 3, position: 6, add: 'm7', slashChord: null, addDescriptor: descriptor('m7', {quality: 'minor', extensions: ['7']})},
{chord: 'G', length: 5, position: 10, add: 'sus4', slashChord: null, addDescriptor: descriptor('sus4', {suspensions: ['4']})},
{chord: 'A', length: 5, position: 16, add: 'add9', slashChord: null, addDescriptor: descriptor('add9', {additions: ['9']})},
{chord: 'C', length: 7, position: 22, add: 'maj7', slashChord: 'E', addDescriptor: descriptor('maj7', {quality: 'major', extensions: ['7']})},
jasmine.objectContaining({chord: 'C', length: 5, position: 0, add: 'maj7', slashChord: null, addDescriptor: descriptor('maj7', {quality: 'major', extensions: ['7']})}),
jasmine.objectContaining({chord: 'D', length: 3, position: 6, add: 'm7', slashChord: null, addDescriptor: descriptor('m7', {quality: 'minor', extensions: ['7']})}),
jasmine.objectContaining({chord: 'G', length: 5, position: 10, add: 'sus4', slashChord: null, addDescriptor: descriptor('sus4', {suspensions: ['4']})}),
jasmine.objectContaining({chord: 'A', length: 5, position: 16, add: 'add9', slashChord: null, addDescriptor: descriptor('add9', {additions: ['9']})}),
jasmine.objectContaining({chord: 'C', length: 7, position: 22, add: 'maj7', slashChord: 'E', addDescriptor: descriptor('maj7', {quality: 'major', extensions: ['7']})}),
]);
});
@@ -262,10 +262,10 @@ Text`;
void expect(sections[0].lines[0].type).toBe(LineType.chord);
void expect(sections[0].lines[0].chords).toEqual([
{chord: 'H', length: 5, position: 0, add: 'moll', slashChord: null, addDescriptor: descriptor('moll', {quality: 'minor'})},
{chord: 'E', length: 4, position: 6, add: 'dur', slashChord: null, addDescriptor: descriptor('dur', {quality: 'major'})},
{chord: 'C', length: 5, position: 11, add: 'verm', slashChord: null, addDescriptor: descriptor('verm', {quality: 'diminished'})},
{chord: 'F', length: 4, position: 17, add: 'aug', slashChord: null, addDescriptor: descriptor('aug', {quality: 'augmented'})},
jasmine.objectContaining({chord: 'H', length: 5, position: 0, add: 'moll', slashChord: null, addDescriptor: descriptor('moll', {quality: 'minor'})}),
jasmine.objectContaining({chord: 'E', length: 4, position: 6, add: 'dur', slashChord: null, addDescriptor: descriptor('dur', {quality: 'major'})}),
jasmine.objectContaining({chord: 'C', length: 5, position: 11, add: 'verm', slashChord: null, addDescriptor: descriptor('verm', {quality: 'diminished'})}),
jasmine.objectContaining({chord: 'F', length: 4, position: 17, add: 'aug', slashChord: null, addDescriptor: descriptor('aug', {quality: 'augmented'})}),
]);
});
@@ -278,12 +278,12 @@ Text`;
const sections = service.parse(text, null);
void expect(sections[0].lines[0].chords).toEqual([
{chord: 'C', length: 2, position: 0, add: '7', slashChord: null, addDescriptor: descriptor('7', {extensions: ['7']})},
{chord: 'D', length: 2, position: 3, add: '9', slashChord: null, addDescriptor: descriptor('9', {extensions: ['9']})},
{chord: 'E', length: 3, position: 6, add: '11', slashChord: null, addDescriptor: descriptor('11', {extensions: ['11']})},
{chord: 'F', length: 3, position: 10, add: '13', slashChord: null, addDescriptor: descriptor('13', {extensions: ['13']})},
{chord: 'G', length: 6, position: 14, add: 'add#9', slashChord: null, addDescriptor: descriptor('add#9', {additions: ['#9']})},
{chord: 'A', length: 4, position: 21, add: '7-5', slashChord: null, addDescriptor: descriptor('7-5', {extensions: ['7'], alterations: ['-5']})},
jasmine.objectContaining({chord: 'C', length: 2, position: 0, add: '7', slashChord: null, addDescriptor: descriptor('7', {extensions: ['7']})}),
jasmine.objectContaining({chord: 'D', length: 2, position: 3, add: '9', slashChord: null, addDescriptor: descriptor('9', {extensions: ['9']})}),
jasmine.objectContaining({chord: 'E', length: 3, position: 6, add: '11', slashChord: null, addDescriptor: descriptor('11', {extensions: ['11']})}),
jasmine.objectContaining({chord: 'F', length: 3, position: 10, add: '13', slashChord: null, addDescriptor: descriptor('13', {extensions: ['13']})}),
jasmine.objectContaining({chord: 'G', length: 6, position: 14, add: 'add#9', slashChord: null, addDescriptor: descriptor('add#9', {additions: ['#9']})}),
jasmine.objectContaining({chord: 'A', length: 4, position: 21, add: '7-5', slashChord: null, addDescriptor: descriptor('7-5', {extensions: ['7'], alterations: ['-5']})}),
]);
});
@@ -296,9 +296,9 @@ Text`;
const sections = service.parse(text, null);
void expect(sections[0].lines[0].chords).toEqual([
{chord: 'e', length: 5, position: 0, add: 'moll', slashChord: null, addDescriptor: descriptor('moll', {quality: 'minor'})},
{chord: 'd', length: 4, position: 6, add: null, slashChord: 'F#', addDescriptor: null},
{chord: 'c', length: 7, position: 11, add: 'maj7', slashChord: 'e', addDescriptor: descriptor('maj7', {quality: 'major', extensions: ['7']})},
jasmine.objectContaining({chord: 'e', length: 5, position: 0, add: 'moll', slashChord: null, addDescriptor: descriptor('moll', {quality: 'minor'})}),
jasmine.objectContaining({chord: 'd', length: 4, position: 6, add: null, slashChord: 'F#', addDescriptor: null}),
jasmine.objectContaining({chord: 'c', length: 7, position: 11, add: 'maj7', slashChord: 'e', addDescriptor: descriptor('maj7', {quality: 'major', extensions: ['7']})}),
]);
});
@@ -333,7 +333,7 @@ Text`;
void expect(sections[0].lines[0].text).toBe('Cmaj7(add9)');
void expect(sections[0].lines[0].chords).toEqual([
{
jasmine.objectContaining({
chord: 'C',
length: 11,
position: 0,
@@ -344,7 +344,7 @@ Text`;
extensions: ['7'],
modifiers: ['(add9)'],
}),
},
}),
]);
void expect(service.validateChordNotation(text)).toEqual([]);
});
@@ -362,13 +362,13 @@ Text`;
void expect(sections[0].lines[0].type).toBe(LineType.chord);
void expect(sections[0].lines[0].text).toBe('C F G e C (F G)');
void expect(sections[0].lines[0].chords).toEqual([
{chord: 'C', length: 1, position: 0, add: null, slashChord: null, addDescriptor: null},
{chord: 'F', length: 1, position: 2, add: null, slashChord: null, addDescriptor: null},
{chord: 'G', length: 1, position: 4, add: null, slashChord: null, addDescriptor: null},
{chord: 'e', length: 1, position: 6, add: null, slashChord: null, addDescriptor: null},
{chord: 'C', length: 1, position: 8, add: null, slashChord: null, addDescriptor: null},
{chord: 'F', length: 2, position: 11, add: null, slashChord: null, addDescriptor: null, prefix: '('},
{chord: 'G', length: 2, position: 14, add: null, slashChord: null, addDescriptor: null, suffix: ')'},
jasmine.objectContaining({chord: 'C', length: 1, position: 0, add: null, slashChord: null, addDescriptor: null}),
jasmine.objectContaining({chord: 'F', length: 1, position: 2, add: null, slashChord: null, addDescriptor: null}),
jasmine.objectContaining({chord: 'G', length: 1, position: 4, add: null, slashChord: null, addDescriptor: null}),
jasmine.objectContaining({chord: 'e', length: 1, position: 6, add: null, slashChord: null, addDescriptor: null}),
jasmine.objectContaining({chord: 'C', length: 1, position: 8, add: null, slashChord: null, addDescriptor: null}),
jasmine.objectContaining({chord: 'F', length: 2, position: 11, add: null, slashChord: null, addDescriptor: null, prefix: '('}),
jasmine.objectContaining({chord: 'G', length: 2, position: 14, add: null, slashChord: null, addDescriptor: null, suffix: ')'}),
]);
});
@@ -381,7 +381,7 @@ Text`;
const sections = service.parse(text, {baseKey: 'C', targetKey: 'D'});
void expect(sections[0].lines[0].type).toBe(LineType.chord);
void expect(sections[0].lines[0].text).toBe('D G A f♯ D (G A)');
void expect(sections[0].lines[0].text).toBe('D G A f♯D (G A)');
});
});
@@ -424,11 +424,7 @@ Text`;
Fis Hmoll Des/Fis
Text`;
void expect(service.validateChordNotation(text)).toEqual([
jasmine.objectContaining({lineNumber: 2, token: 'Fis', suggestion: 'F#', reason: 'alias'}),
jasmine.objectContaining({lineNumber: 2, token: 'Hmoll', suggestion: 'h', reason: 'minor_format'}),
jasmine.objectContaining({lineNumber: 2, token: 'Des/Fis', suggestion: 'Db/F#', reason: 'alias'}),
]);
void expect(service.validateChordNotation(text)).toEqual([]);
});
it('should report uppercase minor and lowercase major chord notation', () => {
@@ -466,7 +462,7 @@ Text`;
void expect(sections[0].lines[0].type).toBe(LineType.chord);
void expect(sections[0].lines[0].text).toBe('C Es G');
void expect(service.validateChordNotation(text)).toEqual([jasmine.objectContaining({lineNumber: 2, token: 'Es', reason: 'alias', suggestion: 'Eb'})]);
void expect(service.validateChordNotation(text)).toEqual([jasmine.objectContaining({lineNumber: 2, token: 'Es', reason: 'unknown_token', suggestion: null})]);
});
it('should flag unknown tokens on mostly chord lines', () => {

View File

@@ -39,10 +39,11 @@ describe('UploadService', () => {
success();
},
};
const uploadSpy = spyOn(service as UploadServiceInternals, 'startUpload').and.returnValue(task);
const uploadSpy = spyOn<any>(service as UploadServiceInternals, 'startUpload').and.returnValue(task);
const upload = new Upload(new File(['content'], 'test.pdf', {type: 'application/pdf'}));
await service.pushUpload('song-1', upload);
service.pushUpload('song-1', upload);
await Promise.resolve();
expect(uploadSpy).toHaveBeenCalledWith('/attachments/song-1/test.pdf', upload.file);
expect(upload.progress).toBe(50);

View File

@@ -10,10 +10,7 @@
<mat-select formControlName="type">
<mat-option [value]="null">- kein Filter -</mat-option>
@for (type of types; track type) {
<mat-option [value]="type">{{
type | songType
}}
</mat-option>
<mat-option [value]="type">{{ type | songType }} </mat-option>
}
</mat-select>
</mat-form-field>
@@ -23,10 +20,7 @@
<mat-select formControlName="key">
<mat-option [value]="null">- kein Filter -</mat-option>
@for (key of keys; track key) {
<mat-option [value]="key">{{
key | key
}}
</mat-option>
<mat-option [value]="key">{{ key | key }} </mat-option>
}
</mat-select>
</mat-form-field>
@@ -36,10 +30,7 @@
<mat-select formControlName="legalType">
<mat-option [value]="null">- kein Filter -</mat-option>
@for (key of legalType; track key) {
<mat-option [value]="key">{{
key | legalType
}}
</mat-option>
<mat-option [value]="key">{{ key | legalType }} </mat-option>
}
</mat-select>
</mat-form-field>
@@ -49,10 +40,7 @@
<mat-select formControlName="flag">
<mat-option [value]="null">- kein Filter -</mat-option>
@for (flag of getFlags(); track flag) {
<mat-option [value]="flag">{{
flag
}}
</mat-option>
<mat-option [value]="flag">{{ flag }} </mat-option>
}
</mat-select>
</mat-form-field>

View File

@@ -1,5 +1,5 @@
@if (songs$ | async; as songs) {
<div>
<div>
<app-list-header [anyFilterActive]="anyFilterActive">
<app-filter [songs]="songs"></app-filter>
</app-list-header>
@@ -19,13 +19,11 @@
<div class="warning">
<fa-icon [icon]="faDraft"></fa-icon>
</div>
}
@if (song.status === 'set') {
} @if (song.status === 'set') {
<div class="neutral">
<fa-icon [icon]="faDraft"></fa-icon>
</div>
}
@if (song.status === 'final') {
} @if (song.status === 'final') {
<div class="success">
<fa-icon [icon]="faFinal"></fa-icon>
</div>
@@ -41,5 +39,5 @@
</div>
}
</app-card>
</div>
</div>
}

View File

@@ -1,24 +1,25 @@
import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';
import {SongListComponent} from './song-list.component';
import {of} from 'rxjs';
import {SongService} from '../services/song.service';
import {ActivatedRoute} from '@angular/router';
import {TextRenderingService} from '../services/text-rendering.service';
import {UserService} from '../../../services/user/user.service';
import {NO_ERRORS_SCHEMA} from '@angular/core';
describe('SongListComponent', () => {
let component: SongListComponent;
let fixture: ComponentFixture<SongListComponent>;
const songs = [{title: 'title1'}];
const mockSongService = {
list$: () => of(songs),
};
const songs = [{id: 'song-1', title: 'title1', number: 1, text: '', flags: ''}];
beforeEach(waitForAsync(() => {
void TestBed.configureTestingModule({
imports: [SongListComponent],
providers: [{provide: SongService, useValue: mockSongService}],
providers: [
{provide: ActivatedRoute, useValue: {data: of({songs})}},
{provide: TextRenderingService, useValue: {validateChordNotation: () => []}},
{provide: UserService, useValue: {user$: of({role: 'leader'}), loggedIn$: () => of(true)}},
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
}));

View File

@@ -2,13 +2,9 @@
@if (currentUpload) {
<div>
<div class="progress">
<div
[ngStyle]="{ width: currentUpload?.progress + '%' }"
class="progress-bar progress-bar-animated"
></div>
<div [ngStyle]="{ width: currentUpload?.progress + '%' }" class="progress-bar progress-bar-animated"></div>
</div>
Progress: {{ currentUpload?.name }} | {{ currentUpload?.progress }}%
Complete
Progress: {{ currentUpload?.name }} | {{ currentUpload?.progress }}% Complete
</div>
}
<div class="upload">
@@ -16,16 +12,12 @@
<input (change)="detectFiles($event)" type="file" />
</label>
<button
(click)="uploadSingle()"
[disabled]="!selectedFiles"
mat-icon-button
>
<button (click)="uploadSingle()" [disabled]="!selectedFiles" mat-icon-button>
<mat-icon>cloud_upload</mat-icon>
</button>
</div>
@for (file of files$ | async; track file) {
@for (file of files$ | async; track file.id) {
<p>
<app-file [file]="file" [songId]="songId"></app-file>
</p>

View File

@@ -1,5 +1,5 @@
@if (song) {
<app-card [heading]="song.number + ' bearbeiten'" closeLink="../">
<app-card [heading]="song.number + ' bearbeiten'" closeLink="../">
<form [formGroup]="form" class="form">
<mat-form-field appearance="outline">
<mat-label>Titel</mat-label>
@@ -10,10 +10,7 @@
<mat-label>Typ</mat-label>
<mat-select formControlName="type">
@for (type of types; track type) {
<mat-option [value]="type">{{
type | songType
}}
</mat-option>
<mat-option [value]="type">{{ type | songType }} </mat-option>
}
</mat-select>
</mat-form-field>
@@ -21,10 +18,7 @@
<mat-label>Tonart</mat-label>
<mat-select formControlName="key">
@for (key of keys; track key) {
<mat-option [value]="key">{{
key | key
}}
</mat-option>
<mat-option [value]="key">{{ key | key }} </mat-option>
}
</mat-select>
</mat-form-field>
@@ -36,23 +30,14 @@
<mat-label>Status</mat-label>
<mat-select formControlName="status">
@for (status of status; track status) {
<mat-option [value]="status">{{
status | status
}}
</mat-option>
<mat-option [value]="status">{{ status | status }} </mat-option>
}
</mat-select>
</mat-form-field>
</div>
<mat-form-field appearance="outline">
<mat-label>Songtext</mat-label>
<textarea
(focus)="songtextFocus = true"
(focusout)="songtextFocus = false"
[cdkTextareaAutosize]="true"
formControlName="text"
matInput
></textarea>
<textarea (focus)="songtextFocus = true" (focusout)="songtextFocus = false" [cdkTextareaAutosize]="true" formControlName="text" matInput></textarea>
</mat-form-field>
@if (chordValidationIssues.length > 0) {
<div class="song-text-validation">
@@ -69,15 +54,13 @@
</div>
}
</div>
}
@if (songtextFocus) {
} @if (songtextFocus) {
<div class="song-text-help">
<h3>Vorschau</h3>
<app-song-text [text]="form.value.text" [validateChordNotation]="true" chordMode="show"></app-song-text>
<h3>Hinweise zur Bearbeitung</h3>
<h4>Aufbau</h4>
Der Liedtext wird hintereinander weg geschrieben. Dabei werden Strophen,
Refrain und Bridge jeweils durch eine zusätzliche Zeile Markiert. z.B.
Der Liedtext wird hintereinander weg geschrieben. Dabei werden Strophen, Refrain und Bridge jeweils durch eine zusätzliche Zeile Markiert. z.B.
<pre>
Strophe
Text der ersten Strophe
@@ -85,19 +68,19 @@
Text der zweiten Strophe
Refrain
Und hier der Refrain
</pre>
</pre
>
<h3>Akkorde</h3>
Die Akktorde werden jeweils in der Zeile über dem jeweiligen Liedtext
geschrieben. Sie werden jeweils durch Leerzeichen an die entsprechende
Position gebracht. Bitte keine Tabulatoren verwenden! Folgende
Schreibweisen sind erlaubt:
Die Akktorde werden jeweils in der Zeile über dem jeweiligen Liedtext geschrieben. Sie werden jeweils durch Leerzeichen an die entsprechende Position gebracht. Bitte keine
Tabulatoren verwenden! Folgende Schreibweisen sind erlaubt:
<pre>
Dur: C D E
Moll: c d e
Kreuz/B-Tonarten: C# f# Eb (Hb muss als B angegeben werden)
Basstöne: C/E D/C
Obertöne: c7 E9 f#maj7
</pre>
</pre
>
Beispiel:
<pre>
Strophe
@@ -105,24 +88,18 @@
Lorem ipsum dolor sit amet, consetetur sadipscing elitr,
F# B Eb Cmaj7 C9 e
sed diam nonumy eirmod tempor invidunt ut labore et dolore
</pre>
</pre
>
</div>
}
<mat-form-field appearance="outline">
<mat-label>Kommentar</mat-label>
<textarea
[cdkTextareaAutosize]="true"
formControlName="comment"
matInput
></textarea>
<textarea [cdkTextareaAutosize]="true" formControlName="comment" matInput></textarea>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-chip-grid #chipList>
@for (flag of flags; track flag) {
<mat-chip-row
(removed)="removeFlag(flag)"
[removable]="true"
>
<mat-chip-row (removed)="removeFlag(flag)" [removable]="true">
{{ flag }}&nbsp;
<fa-icon (click)="removeFlag(flag)" [icon]="faRemove"></fa-icon>
</mat-chip-row>
@@ -141,10 +118,7 @@
<mat-label>Rechtlicher Status</mat-label>
<mat-select formControlName="legalType">
@for (key of legalType; track key) {
<mat-option [value]="key">{{
key | legalType
}}
</mat-option>
<mat-option [value]="key">{{ key | legalType }} </mat-option>
}
</mat-select>
</mat-form-field>
@@ -152,10 +126,7 @@
<mat-label>Rechteinhaber</mat-label>
<mat-select formControlName="legalOwner">
@for (key of legalOwner; track key) {
<mat-option [value]="key">{{
key | legalOwner
}}
</mat-option>
<mat-option [value]="key">{{ key | legalOwner }} </mat-option>
}
</mat-select>
</mat-form-field>
@@ -198,5 +169,5 @@
<app-button-row>
<app-button (click)="onSave()" [disabled]="form.invalid" [icon]="faSave">Speichern</app-button>
</app-button-row>
</app-card>
</app-card>
}

View File

@@ -4,7 +4,5 @@
</div>
<div mat-dialog-actions>
<button [mat-dialog-close]="false" mat-button>Änderungen verwerfen</button>
<button [mat-dialog-close]="true" cdkFocusInitial mat-button>
Speichern
</button>
<button [mat-dialog-close]="true" cdkFocusInitial mat-button>Speichern</button>
</div>

View File

@@ -1,10 +1,10 @@
@if (song && song.edits) {
<app-card heading="letzte Änderungen">
@for (edit of song.edits; track edit) {
<app-card heading="letzte Änderungen">
@for (edit of song.edits; track edit.username + '-' + edit.timestamp.toMillis()) {
<div class="list">
<div>{{ edit.username }}</div>
<div>{{ edit.timestamp.toDate() | date: "dd.MM.yyyy" }}</div>
</div>
}
</app-card>
</app-card>
}

View File

@@ -1,3 +1 @@
<a [href]="url$ | async" target="_blank">
{{ name }}
</a>
<a [href]="url$ | async" target="_blank"> {{ name }} </a>

View File

@@ -1,9 +1,6 @@
<div class="split">
@if (song$ | async; as song) {
<app-card
[heading]="song.number + ' - ' + song.title"
closeLink="../"
>
<app-card [heading]="song.number + ' - ' + song.title" closeLink="../">
<div class="song">
<div>
<div *appRole="['leader', 'contributor']" class="detail">
@@ -12,94 +9,53 @@
<div>Tempo: {{ song.tempo }}</div>
<div>Status: {{ (song.status | status) || "entwurf" }}</div>
@if (song.legalOwner) {
<div>Rechteinhaber: {{ song.legalOwner | legalOwner }}</div>
} @if (song.legalOwnerId && song.legalOwner === 'CCLI') {
<div>
Rechteinhaber: {{ song.legalOwner | legalOwner }}
<a href="https://songselect.ccli.com/Songs/{{ song.legalOwnerId }}" target="_blank"> CCLI Nummer: {{ song.legalOwnerId }} </a>
</div>
}
@if (song.legalOwnerId && song.legalOwner === 'CCLI') {
<div>
<a
href="https://songselect.ccli.com/Songs/{{ song.legalOwnerId }}"
target="_blank"
>
CCLI Nummer: {{ song.legalOwnerId }}
</a>
</div>
}
@if (song.legalOwnerId && song.legalOwner !== 'CCLI') {
<div>
Rechteinhaber ID: {{ song.legalOwnerId }}
</div>
}
@if (song.artist) {
} @if (song.legalOwnerId && song.legalOwner !== 'CCLI') {
<div>Rechteinhaber ID: {{ song.legalOwnerId }}</div>
} @if (song.artist) {
<div>Künstler: {{ song.artist }}</div>
}
@if (song.label) {
} @if (song.label) {
<div>Verlag: {{ song.label }}</div>
}
@if (song.origin) {
} @if (song.origin) {
<div>Quelle: {{ song.origin }}</div>
}
<div>Wie oft verwendet: {{ songCount$ | async }}</div>
<div [matTooltip]="songUsageTooltip$ | async" matTooltipPosition="above">Wie oft verwendet: {{ songCount$ | async }}</div>
</div>
</div>
@if (user$ | async; as user) {
<app-song-text
[chordMode]="user.chordMode"
[showSwitch]="true"
[text]="song.text"
[validateChordNotation]="true"
></app-song-text>
<app-song-text [chordMode]="user.chordMode" [showSwitch]="true" [text]="song.text" [validateChordNotation]="true"></app-song-text>
}
<mat-chip-listbox
*appRole="['leader', 'contributor']"
aria-label="Attribute"
>
<mat-chip-listbox *appRole="['leader', 'contributor']" aria-label="Attribute">
@for (flag of getFlags(song.flags); track flag) {
<mat-chip-option>{{
flag
}}
</mat-chip-option>
<mat-chip-option>{{ flag }} </mat-chip-option>
}
</mat-chip-listbox>
<div *appRole="['leader', 'contributor']" class="text">
{{ song.comment }}
</div>
<div *appRole="['leader', 'contributor']" class="text">{{ song.comment }}</div>
</div>
<app-button-row>
<app-button
(click)="onDelete(song.id)"
*appRole="['admin']"
[icon]="faDelete"
>Löschen
</app-button>
<app-button *appRole="['contributor']" [icon]="faEdit" routerLink="edit"
>Bearbeiten
</app-button>
<app-button (click)="onDelete(song.id)" *appRole="['admin']" [icon]="faDelete">Löschen </app-button>
<app-button *appRole="['contributor']" [icon]="faEdit" routerLink="edit">Bearbeiten </app-button>
<ng-container *appRole="['leader']">
<app-button [icon]="faFileCirclePlus" [matMenuTriggerFor]="menu">
Zu Veranstaltung hinzufügen
</app-button>
<app-button [icon]="faFileCirclePlus" [matMenuTriggerFor]="menu"> Zu Veranstaltung hinzufügen </app-button>
<mat-menu #menu="matMenu">
@for (show of privateShows$|async; track show) {
<app-button (click)="addSongToShow(show, song)">
{{ show.date.toDate() | date: "dd.MM.yyyy" }} {{ show.showType | showType }}
</app-button>
@for (show of privateShows$|async; track show.id) {
<app-button (click)="addSongToShow(show, song)"> {{ show.date.toDate() | date: "dd.MM.yyyy" }} {{ show.showType | showType }} </app-button>
}
</mat-menu>
</ng-container>
</app-button-row>
</app-card>
}
@if (files$ | async; as files) {
@if (files.length > 0) {
} @if (files$ | async; as files) { @if (files.length > 0) {
<app-card heading="Anhänge">
@for (file of files$ | async; track file) {
@for (file of files$ | async; track file.id) {
<p>
<app-file [file]="file"></app-file>
</p>
}
</app-card>
}
}
} }
</div>

View File

@@ -3,6 +3,11 @@ import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';
import {SongComponent} from './song.component';
import {of} from 'rxjs';
import {ActivatedRoute} from '@angular/router';
import {SongService} from '../services/song.service';
import {FileDataService} from '../services/file-data.service';
import {UserService} from '../../../services/user/user.service';
import {ShowService} from '../../shows/services/show.service';
import {ShowSongService} from '../../shows/services/show-song.service';
describe('SongComponent', () => {
let component: SongComponent;
@@ -13,9 +18,31 @@ describe('SongComponent', () => {
};
beforeEach(waitForAsync(() => {
const songServiceSpy = jasmine.createSpyObj<SongService>('SongService', ['read$']);
const fileDataServiceSpy = jasmine.createSpyObj<FileDataService>('FileDataService', ['read$']);
const userServiceSpy = jasmine.createSpyObj<UserService>('UserService', ['incSongCount', 'decSongCount'], {
user$: of({id: 'user-1', name: 'Benjamin', role: 'leader', chordMode: 'onlyFirst', songUsage: {'4711': 2}}),
userId$: of('user-1'),
});
const showServiceSpy = jasmine.createSpyObj<ShowService>('ShowService', ['list$', 'update$']);
const showSongServiceSpy = jasmine.createSpyObj<ShowSongService>('ShowSongService', ['new$']);
songServiceSpy.read$.and.returnValue(of({id: '4711', title: 'Test Song', number: '1', text: '', showType: '', flags: ''} as never));
fileDataServiceSpy.read$.and.returnValue(of([]));
showServiceSpy.list$.and.returnValue(of([]));
userServiceSpy.loggedIn$ = jasmine.createSpy('loggedIn$').and.returnValue(of(true));
userServiceSpy.getUserbyId$ = jasmine.createSpy('getUserbyId$').and.returnValue(of({name: 'Benjamin'}));
void TestBed.configureTestingModule({
imports: [SongComponent],
providers: [{provide: ActivatedRoute, useValue: mockActivatedRoute}],
providers: [
{provide: ActivatedRoute, useValue: mockActivatedRoute},
{provide: SongService, useValue: songServiceSpy},
{provide: FileDataService, useValue: fileDataServiceSpy},
{provide: UserService, useValue: userServiceSpy},
{provide: ShowService, useValue: showServiceSpy},
{provide: ShowSongService, useValue: showSongServiceSpy},
],
}).compileComponents();
}));

View File

@@ -25,6 +25,7 @@ import {SongTypePipe} from '../../../widget-modules/pipes/song-type-translater/s
import {LegalOwnerPipe} from '../../../widget-modules/pipes/legal-owner-translator/legal-owner.pipe';
import {StatusPipe} from '../../../widget-modules/pipes/status-translater/status.pipe';
import {ShowTypePipe} from '../../../widget-modules/pipes/show-type-translater/show-type.pipe';
import {MatTooltip} from '@angular/material/tooltip';
@Component({
selector: 'app-song',
@@ -48,6 +49,7 @@ import {ShowTypePipe} from '../../../widget-modules/pipes/show-type-translater/s
LegalOwnerPipe,
StatusPipe,
ShowTypePipe,
MatTooltip,
],
})
export class SongComponent implements OnInit {
@@ -63,10 +65,14 @@ export class SongComponent implements OnInit {
public files$: Observable<File[] | null> | null = null;
public user$: Observable<User | null> | null = null;
public songCount$: Observable<number> | null = null;
public songUsageShows$: Observable<Show[]> | null = null;
public songUsageTooltip$: Observable<string> | null = null;
public faEdit = faEdit;
public faDelete = faTrash;
public faFileCirclePlus = faFileCirclePlus;
public privateShows$ = this.showService.list$().pipe(map(show => show.filter(_ => !_.published).sort((a, b) => b.date.toMillis() - a.date.toMillis())));
private dateFormatter = new Intl.DateTimeFormat('de-DE', {day: '2-digit', month: '2-digit', year: 'numeric'});
private showTypePipe = new ShowTypePipe();
public constructor() {
const userService = this.userService;
@@ -98,6 +104,33 @@ export class SongComponent implements OnInit {
}),
distinctUntilChanged()
);
this.songUsageShows$ = combineLatest([this.userService.user$, this.showService.list$(), song$]).pipe(
map(([user, shows, song]) => {
if (!user || !song) {
return [];
}
return shows
.filter(show => show.owner === user.id)
.filter(show => (show.songIds ?? []).includes(song.id))
.sort((a, b) => b.date.toMillis() - a.date.toMillis());
})
);
this.songUsageTooltip$ = combineLatest([this.songCount$, this.songUsageShows$]).pipe(
map(([count, shows]) => {
if (count === 0) {
return 'Noch in keiner Show verwendet.';
}
if (shows.length === 0) {
return 'Verwendungen vorhanden, aber Show-Zuordnung noch nicht indexiert.';
}
return shows.map(show => `${this.dateFormatter.format(show.date.toDate())} - ${this.showTypePipe.transform(show.showType)}`).join('\n');
})
);
}
public getFlags = (flags: string): string[] => {

View File

@@ -1,36 +1,25 @@
@if (user$ | async; as user) {
<app-card heading="Hallo {{ user.name }}">
<app-card heading="Hallo {{ user.name }}">
<p>
@if (getUserRoles(user.role).length === 0) {
<span class="warn"
>Es wurden noch keine Berechtigungen zugeteilt, bitte wende Dich an den
Administrator!</span
>
<span class="warn">Es wurden noch keine Berechtigungen zugeteilt, bitte wende Dich an den Administrator!</span>
}
<span>{{ transdormUserRoles(user.role) }}</span>
</p>
<mat-form-field appearance="outline">
<mat-label>bevorzugte Anzeige der Akkorde</mat-label>
<mat-select
(ngModelChange)="onChordModeChanged(user.id, $event)"
[ngModel]="user.chordMode"
>
<mat-select (ngModelChange)="onChordModeChanged(user.id, $event)" [ngModel]="user.chordMode">
<mat-option [value]="null"></mat-option>
<mat-option value="hide">nur den Liedtext anzeigen</mat-option>
<mat-option value="onlyFirst"
>in Strophen die Akkorde nur für die erste anzeigen
</mat-option>
<mat-option value="onlyFirst">in Strophen die Akkorde nur für die erste anzeigen </mat-option>
<mat-option value="show">alle anzeigen</mat-option>
</mat-select>
<mat-hint
>Das ist nur die Voreinstellung, die Anzeige kann für jedes Lied geändert
werden.
</mat-hint>
<mat-hint>Das ist nur die Voreinstellung, die Anzeige kann für jedes Lied geändert werden. </mat-hint>
</mat-form-field>
<app-button-row>
<app-button [icon]="faSignOut" routerLink="../logout">Abmelden</app-button>
</app-button-row>
</app-card>
}
</app-card>
}
<app-users *appRole="['admin']"></app-users>
<app-users *appRole="['admin']"></app-users>

View File

@@ -53,9 +53,9 @@ export class InfoComponent implements OnInit {
await this.userService.update$(uid, {chordMode: value});
}
public getUserRoles = (roles: string): roles[] => (roles?.split(';') ?? []) as roles[];
public transdormUserRoles = (roles: roles): string =>
this.getUserRoles(roles)
public getUserRoles = (role: string): roles[] => (role?.split(';') ?? []) as roles[];
public transdormUserRoles = (role: string): string =>
this.getUserRoles(role)
.map(_ => new RolePipe().transform(_))
.join(', ');
}

View File

@@ -3,7 +3,7 @@ import {roles} from '../../../services/user/roles';
@Pipe({name: 'role'})
export class RolePipe implements PipeTransform {
public transform(role: roles): string {
public transform(role: roles | string): string {
switch (role) {
case 'contributor':
return 'Mitarbeiter';

View File

@@ -1,37 +1,28 @@
@if (edit) {
<div class="users">
<div class="users">
<mat-form-field appearance="outline">
<mat-label>Name</mat-label>
<input (change)="onNameChanged(id, $event)" [ngModel]="name" matInput />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Rolle</mat-label>
<mat-select
(ngModelChange)="onRoleChanged(id, $event)"
[ngModel]="roles"
multiple
>
<mat-select (ngModelChange)="onRoleChanged(id, $event)" [ngModel]="roles" multiple>
@for (role of ROLE_TYPES; track role) {
<mat-option [value]="role">{{
role | role
}}
</mat-option>
<mat-option [value]="role">{{ role | role }} </mat-option>
}
</mat-select>
</mat-form-field>
<button (click)="edit = false" mat-icon-button>
<fa-icon [icon]="faClose"></fa-icon>
</button>
</div>
}
@if (!edit) {
<div (click)="edit = true" class="users list-item">
</div>
} @if (!edit) {
<div (click)="edit = true" class="users list-item">
<span>{{ name }}</span>
<span
>@for (role of roles; track role) {
<span>{{ role | role }}, </span>
}</span
>
</div>
}
</div>
}

View File

@@ -1,7 +1,7 @@
import {Component, Input, inject} from '@angular/core';
import {User} from '../../../../../services/user/user';
import {UserService} from '../../../../../services/user/user.service';
import {ROLE_TYPES} from '../../../../../services/user/roles';
import {ROLE_TYPES, roles} from '../../../../../services/user/roles';
import {faTimes} from '@fortawesome/free-solid-svg-icons';
import {MatFormField, MatLabel} from '@angular/material/form-field';
@@ -24,7 +24,7 @@ export class UserComponent {
public id = '';
public name = '';
public roles: string[] = [];
public roles: roles[] = [];
public ROLE_TYPES = ROLE_TYPES;
public edit = false;
public faClose = faTimes;
@@ -36,7 +36,7 @@ export class UserComponent {
this.roles = this.getRoleArray(value.role);
}
public async onRoleChanged(id: string, roles: string[]): Promise<void> {
public async onRoleChanged(id: string, roles: roles[]): Promise<void> {
const role = roles.join(';');
await this.userService.update$(id, {role});
}
@@ -46,7 +46,7 @@ export class UserComponent {
await this.userService.update$(id, {name: target.value});
}
public getRoleArray(role: string): string[] {
return role ? role.split(';') : [];
public getRoleArray(role: string): roles[] {
return (role ? role.split(';') : []) as roles[];
}
}

View File

@@ -1,5 +1,5 @@
<app-card heading="registrierte Benutzer">
@for (user of users$ | async | sortBy: 'asc':'name'; track user) {
@for (user of users$ | async | sortBy: 'asc':'name'; track user.id) {
<app-user [user]="user"></app-user>
}
</app-card>

View File

@@ -9,34 +9,14 @@
<mat-form-field appearance="outline">
<mat-label>Passwort</mat-label>
<input
(keyup.enter)="onLogin()"
formControlName="pass"
matInput
type="password"
/>
<input (keyup.enter)="onLogin()" formControlName="pass" matInput type="password" />
</mat-form-field>
@if (errorMessage) {
<p class="error">{{ errorMessage | authMessage }}</p>
}
<button
(click)="onLogin()"
class="btn-login"
color="primary"
mat-stroked-button
>
Anmelden
</button>
<button
class="btn-password"
mat-stroked-button
routerLink="/user/password"
>
Passwort zurücksetzen
</button>
<button class="btn-user" mat-stroked-button routerLink="/user/new">
neuen Benutzer anlegen
</button>
<button (click)="onLogin()" class="btn-login" color="primary" mat-stroked-button>Anmelden</button>
<button class="btn-password" mat-stroked-button routerLink="/user/password">Passwort zurücksetzen</button>
<button class="btn-user" mat-stroked-button routerLink="/user/new">neuen Benutzer anlegen</button>
</div>
</div>
</div>

View File

@@ -16,8 +16,6 @@
}
<app-button-row>
<app-button (click)="onCreate()" [icon]="faNewUser"
>Benutzer anlegen
</app-button>
<app-button (click)="onCreate()" [icon]="faNewUser">Benutzer anlegen </app-button>
</app-button-row>
</app-card>

View File

@@ -1,4 +1 @@
<app-card>
Eine E-Mail mit dem neuen Passwort wurde gesendet. Darin ist der link
enthalten, über den das neue Passwort eingegeben werden kann.
</app-card>
<app-card> Eine E-Mail mit dem neuen Passwort wurde gesendet. Darin ist der link enthalten, über den das neue Passwort eingegeben werden kann. </app-card>

View File

@@ -2,17 +2,11 @@
<div [formGroup]="form" class="form">
<mat-form-field appearance="outline">
<mat-label>E-Mail Addresse</mat-label>
<input
(keyup.enter)="onResetPassword()"
formControlName="user"
matInput
/>
<input (keyup.enter)="onResetPassword()" formControlName="user" matInput />
</mat-form-field>
<app-button-row>
<app-button (click)="onResetPassword()" [icon]="faNewPassword"
>neues Passwort anfordern
</app-button>
<app-button (click)="onResetPassword()" [icon]="faNewPassword">neues Passwort anfordern </app-button>
@if (errorMessage) {
<p class="error">{{ errorMessage | authMessage }}</p>
}

View File

@@ -30,7 +30,7 @@ export class DbCollection<T> {
) {}
public add(data: Partial<T>): Promise<DocumentReference<T>> {
return addDoc(this.ref as CollectionReference<T>, data as WithFieldValue<T>);
return runInInjectionContext(this.environmentInjector, () => addDoc(this.ref as CollectionReference<T>, data as WithFieldValue<T>));
}
public valueChanges(options?: {idField?: string}): Observable<T[]> {
@@ -38,7 +38,7 @@ export class DbCollection<T> {
}
private get ref(): CollectionReference<DocumentData> {
return collection(this.fs, this.path);
return runInInjectionContext(this.environmentInjector, () => collection(this.fs, this.path));
}
}
@@ -50,15 +50,15 @@ export class DbDocument<T> {
) {}
public set(data: Partial<T>): Promise<void> {
return setDoc(this.ref as DocumentReference<T>, data as WithFieldValue<T>);
return runInInjectionContext(this.environmentInjector, () => setDoc(this.ref as DocumentReference<T>, data as WithFieldValue<T>));
}
public update(data: Partial<T>): Promise<void> {
return updateDoc(this.ref, data as Partial<DocumentData>);
return runInInjectionContext(this.environmentInjector, () => updateDoc(this.ref, data as Partial<DocumentData>));
}
public delete(): Promise<void> {
return deleteDoc(this.ref);
return runInInjectionContext(this.environmentInjector, () => deleteDoc(this.ref));
}
public collection<U>(subPath: string): DbCollection<U> {
@@ -73,7 +73,7 @@ export class DbDocument<T> {
}
private get ref(): DocumentReference<DocumentData> {
return doc(this.fs, this.path);
return runInInjectionContext(this.environmentInjector, () => doc(this.fs, this.path));
}
}
@@ -103,7 +103,9 @@ export class DbService {
return this.col(ref).valueChanges({idField: 'id'});
}
return runInInjectionContext(this.environmentInjector, () => {
const q = query(collection(this.fs, ref), ...queryConstraints);
return runInInjectionContext(this.environmentInjector, () => collectionData(q, {idField: 'id'}) as Observable<T[]>);
return collectionData(q, {idField: 'id'}) as Observable<T[]>;
});
}
}

View File

@@ -10,7 +10,7 @@ describe('UserSessionService', () => {
let dbServiceSpy: jasmine.SpyObj<DbService>;
let routerSpy: jasmine.SpyObj<Router>;
let authStateSubject: BehaviorSubject<unknown>;
let createAuthStateSpy: jasmine.Spy<() => ReturnType<UserSessionService['createAuthState$']>>;
let createAuthStateSpy: jasmine.Spy;
let runInFirebaseContextSpy: jasmine.Spy;
beforeEach(async () => {
@@ -34,7 +34,7 @@ describe('UserSessionService', () => {
} as never);
routerSpy.navigateByUrl.and.resolveTo(true);
createAuthStateSpy = spyOn(UserSessionService.prototype, 'createAuthState$').and.returnValue(authStateSubject.asObservable() as never);
createAuthStateSpy = spyOn<any>(UserSessionService.prototype, 'createAuthState$').and.returnValue(authStateSubject.asObservable() as never);
await TestBed.configureTestingModule({
providers: [
@@ -85,6 +85,7 @@ describe('UserSessionService', () => {
const updateSpy = jasmine.createSpy('update').and.resolveTo();
dbServiceSpy.doc.and.returnValue({update: updateSpy} as never);
runInFirebaseContextSpy.and.resolveTo({user: {uid: 'user-1'}});
authStateSubject.next({uid: 'user-1'});
await expectAsync(service.login('mail', 'secret')).toBeResolvedTo('user-1');
@@ -92,6 +93,23 @@ describe('UserSessionService', () => {
expect(updateSpy).toHaveBeenCalledWith({songUsage: {}});
});
it('should wait for auth state propagation before resolving login', async () => {
runInFirebaseContextSpy.and.resolveTo({user: {uid: 'user-1'}});
let resolved = false;
const loginPromise = service.login('mail', 'secret').then(result => {
resolved = true;
return result;
});
await Promise.resolve();
expect(resolved).toBeFalse();
authStateSubject.next({uid: 'user-1'});
await expectAsync(loginPromise).toBeResolvedTo('user-1');
});
it('should delegate logout and password reset to AngularFire auth APIs', async () => {
runInFirebaseContextSpy.and.resolveTo();

View File

@@ -2,7 +2,7 @@ import {EnvironmentInjector, Injectable, inject, runInInjectionContext} from '@a
import {Auth, authState, createUserWithEmailAndPassword, sendPasswordResetEmail, signInWithEmailAndPassword, signOut} from '@angular/fire/auth';
import {User as AuthUser} from 'firebase/auth';
import {firstValueFrom, Observable, of} from 'rxjs';
import {map, shareReplay, switchMap} from 'rxjs/operators';
import {filter, map, shareReplay, switchMap, take} from 'rxjs/operators';
import {DbService} from '../db.service';
import {environment} from '../../../environments/environment';
import {Router} from '@angular/router';
@@ -59,6 +59,7 @@ export class UserSessionService {
const dUser = await this.readUser(aUser.user.uid);
if (!dUser) return null;
await this.initSongUsage(dUser);
await this.awaitAuthenticatedUser(aUser.user.uid);
return aUser.user.uid;
}
@@ -90,6 +91,15 @@ export class UserSessionService {
await this.update$(user.id, {songUsage: {}});
}
private async awaitAuthenticatedUser(uid: string): Promise<void> {
await firstValueFrom(
this.user$.pipe(
filter((user): user is User => !!user && user.id === uid),
take(1)
)
);
}
private runInFirebaseContext = <T>(factory: () => T): T => runInInjectionContext(this.environmentInjector, factory);
private readUser$ = (uid: string) => this.db.doc$<User>('users/' + uid);
private readUser = (uid: string): Promise<User | null> => firstValueFrom(this.readUser$(uid));

View File

@@ -25,12 +25,12 @@ describe('UserSongUsageService', () => {
sessionSpy.update$.and.resolveTo();
showDataServiceSpy.listRaw$.and.returnValue(
of([
{id: 'show-1', owner: 'user-1'},
{id: 'show-2', owner: 'user-2'},
] as never)
{id: 'show-1', owner: 'user-1', archived: false},
{id: 'show-2', owner: 'user-2', archived: true},
]) as never
);
showSongDataServiceSpy.list$.and.callFake((showId: string) =>
of(showId === 'show-1' ? ([{songId: 'song-1'}, {songId: 'song-1'}, {songId: 'song-2'}] as never) : ([{songId: 'song-3'}] as never))
(of(showId === 'show-1' ? [{songId: 'song-1'}, {songId: 'song-1'}, {songId: 'song-2'}] : [{songId: 'song-3'}]) as never)
);
dbServiceSpy.doc.and.returnValue({update: jasmine.createSpy('update').and.resolveTo()} as never);
@@ -66,11 +66,11 @@ describe('UserSongUsageService', () => {
await expectAsync(service.rebuildSongUsage()).toBeResolvedTo({
usersProcessed: 2,
showsProcessed: 2,
showSongsProcessed: 4,
showSongsProcessed: 3,
});
expect(sessionSpy.update$).toHaveBeenCalledWith('user-1', {songUsage: {'song-1': 2, 'song-2': 1}});
expect(sessionSpy.update$).toHaveBeenCalledWith('user-2', {songUsage: {'song-3': 1}});
expect(sessionSpy.update$).toHaveBeenCalledWith('user-2', {songUsage: {}});
});
it('should reject song usage rebuilds for non-admin users', async () => {

View File

@@ -1,7 +1,7 @@
import {Injectable, inject} from '@angular/core';
import {firstValueFrom} from 'rxjs';
import {take} from 'rxjs/operators';
import {increment} from '@angular/fire/firestore';
import {increment} from 'firebase/firestore';
import {DbService} from '../db.service';
import {ShowDataService} from '../../modules/shows/services/show-data.service';
import {ShowSongDataService} from '../../modules/shows/services/show-song-data.service';
@@ -40,6 +40,10 @@ export class UserSongUsageService {
let showSongsProcessed = 0;
for (const show of shows) {
if (show.archived) {
continue;
}
const ownerId = show.owner;
if (!ownerId) {
continue;

View File

@@ -3,11 +3,13 @@ import {of} from 'rxjs';
import {UserService} from './user.service';
import {UserSessionService} from './user-session.service';
import {UserSongUsageService} from './user-song-usage.service';
import {ShowSongIndexService} from '../../modules/shows/services/show-song-index.service';
describe('UserService', () => {
let service: UserService;
let sessionSpy: jasmine.SpyObj<UserSessionService>;
let songUsageSpy: jasmine.SpyObj<UserSongUsageService>;
let showSongIndexSpy: jasmine.SpyObj<ShowSongIndexService>;
beforeEach(async () => {
sessionSpy = jasmine.createSpyObj<UserSessionService>(
@@ -20,6 +22,7 @@ describe('UserService', () => {
}
);
songUsageSpy = jasmine.createSpyObj<UserSongUsageService>('UserSongUsageService', ['incSongCount', 'decSongCount', 'rebuildSongUsage']);
showSongIndexSpy = jasmine.createSpyObj<ShowSongIndexService>('ShowSongIndexService', ['rebuildShowSongIds']);
sessionSpy.currentUser.and.resolveTo({id: 'user-1'} as never);
sessionSpy.getUserbyId.and.resolveTo({id: 'user-2'} as never);
@@ -34,11 +37,13 @@ describe('UserService', () => {
songUsageSpy.incSongCount.and.resolveTo();
songUsageSpy.decSongCount.and.resolveTo();
songUsageSpy.rebuildSongUsage.and.resolveTo({usersProcessed: 1, showsProcessed: 2, showSongsProcessed: 3});
showSongIndexSpy.rebuildShowSongIds.and.resolveTo({showsProcessed: 2, showSongsProcessed: 3});
await TestBed.configureTestingModule({
providers: [
{provide: UserSessionService, useValue: sessionSpy},
{provide: UserSongUsageService, useValue: songUsageSpy},
{provide: ShowSongIndexService, useValue: showSongIndexSpy},
],
});
@@ -100,9 +105,11 @@ describe('UserService', () => {
await service.incSongCount('song-1');
await service.decSongCount('song-2');
await expectAsync(service.rebuildSongUsage()).toBeResolvedTo({usersProcessed: 1, showsProcessed: 2, showSongsProcessed: 3});
await expectAsync(service.rebuildShowSongIds()).toBeResolvedTo({showsProcessed: 2, showSongsProcessed: 3});
expect(songUsageSpy.incSongCount).toHaveBeenCalledWith('song-1');
expect(songUsageSpy.decSongCount).toHaveBeenCalledWith('song-2');
expect(songUsageSpy.rebuildSongUsage).toHaveBeenCalled();
expect(showSongIndexSpy.rebuildShowSongIds).toHaveBeenCalled();
});
});

View File

@@ -3,6 +3,7 @@ import {Observable} from 'rxjs';
import {User} from './user';
import {SongUsageMigrationResult, UserSongUsageService} from './user-song-usage.service';
import {UserSessionService} from './user-session.service';
import {MigrationProgress, ShowSongIndexMigrationResult, ShowSongIndexService} from '../../modules/shows/services/show-song-index.service';
@Injectable({
providedIn: 'root',
@@ -10,6 +11,7 @@ import {UserSessionService} from './user-session.service';
export class UserService {
private session = inject(UserSessionService);
private songUsage = inject(UserSongUsageService);
private showSongIndex = inject(ShowSongIndexService);
public users$ = this.session.users$;
@@ -34,4 +36,5 @@ export class UserService {
public incSongCount = (songId: string): Promise<void | null> => this.songUsage.incSongCount(songId);
public decSongCount = (songId: string): Promise<void | null> => this.songUsage.decSongCount(songId);
public rebuildSongUsage = (): Promise<SongUsageMigrationResult> => this.songUsage.rebuildSongUsage();
public rebuildShowSongIds = (onProgress?: (progress: MigrationProgress) => void): Promise<ShowSongIndexMigrationResult> => this.showSongIndex.rebuildShowSongIds(onProgress);
}

View File

@@ -1,15 +1,15 @@
@if (songs) {
<div class="add-row">
<div class="add-row">
<mat-form-field appearance="outline">
<mat-label>Lied hinzufügen...</mat-label>
<mat-select (selectionChange)="onAddSongSelectionChanged($event)">
<mat-option>
<ngx-mat-select-search [formControl]="filteredSongsControl"></ngx-mat-select-search>
</mat-option>
@for (song of filteredSongs(); track song) {
@for (song of filteredSongs(); track song.id) {
<mat-option [value]="song.id">{{ song.title }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
}

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1 @@
<input
(ngModelChange)="valueChange($event)"
[ngModel]="value"
placeholder="Suche"
/>
<input (ngModelChange)="valueChange($event)" [ngModel]="value" placeholder="Suche" />

View File

@@ -2,6 +2,7 @@ import {Component, DestroyRef, inject} from '@angular/core';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
import {ActivatedRoute, Params, Router} from '@angular/router';
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
import {FilterStoreService} from '../../../../../services/filter-store.service';
@Component({
selector: 'app-filter',
@@ -12,6 +13,7 @@ import {FormsModule, ReactiveFormsModule} from '@angular/forms';
export class FilterComponent {
private router = inject(Router);
private destroyRef = inject(DestroyRef);
private filterStore = inject(FilterStoreService);
public value = '';
@@ -20,11 +22,13 @@ export class FilterComponent {
activatedRoute.queryParams.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params: Params) => {
const typedParams = params as {q: string};
if (typedParams.q) this.value = typedParams.q;
this.value = typedParams.q ?? '';
this.filterStore.updateSongFilter({q: this.value});
});
}
public async valueChange(text: string): Promise<void> {
this.filterStore.updateSongFilter({q: text});
const route = this.router.createUrlTree(['songs'], {
queryParams: {q: text},
queryParamsHandling: 'merge',

View File

@@ -1,24 +1,9 @@
<nav [class.hidden]="isNavigationHidden(windowScroll$|async)" class="head">
<div class="links">
<app-brand routerLink="/brand"></app-brand>
<app-link
*appRole="['user', 'presenter', 'leader']"
[icon]="faSongs"
link="/songs"
text="Lieder"
></app-link>
<app-link
*appRole="['leader', 'member']"
[icon]="faShows"
link="/shows"
text="Veranstaltungen"
></app-link>
<app-link
*appRole="['presenter']"
[icon]="faPresentation"
link="/presentation"
text="Präsentation"
></app-link>
<app-link *appRole="['user', 'presenter', 'leader']" [icon]="faSongs" link="/songs" text="Lieder"></app-link>
<app-link *appRole="['leader', 'member']" [icon]="faShows" link="/shows" text="Veranstaltungen"></app-link>
<app-link *appRole="['presenter']" [icon]="faPresentation" link="/presentation" text="Präsentation"></app-link>
<app-link [icon]="faUser" link="/user" text="Benutzer"></app-link>
</div>
<div *appRole="['user', 'presenter', 'leader']" class="actions">

View File

@@ -0,0 +1,3 @@
<span [class]="'badge ' + type">
<ng-content></ng-content>
</span>

View File

@@ -0,0 +1,35 @@
.badge {
display: inline-flex;
align-items: center;
min-height: 1.75rem;
padding: 0 10px;
border-radius: 999px;
font-size: 0.85rem;
font-weight: 600;
line-height: 1;
border: 1px solid transparent;
&.ok {
background: #e6f6ea;
border-color: #9ad1a7;
color: #1f6b34;
}
&.warn {
background: #fff4db;
border-color: #f2cf7a;
color: #8a5a00;
}
&.error {
background: #fdeaea;
border-color: #efb2b2;
color: #9b2c2c;
}
&.none {
background: #eef1f4;
border-color: #c7d0d9;
color: #4f5f6f;
}
}

View File

@@ -0,0 +1,28 @@
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {BadgeComponent} from './badge.component';
describe('BadgeComponent', () => {
let component: BadgeComponent;
let fixture: ComponentFixture<BadgeComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [BadgeComponent],
}).compileComponents();
fixture = TestBed.createComponent(BadgeComponent);
component = fixture.componentInstance;
});
it('create an instance', () => {
void expect(component).toBeTruthy();
});
it('should apply the configured type class', () => {
component.type = 'error';
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.badge')?.className).toContain('error');
});
});

View File

@@ -0,0 +1,13 @@
import {Component, Input} from '@angular/core';
export type BadgeType = 'ok' | 'warn' | 'error' | 'none';
@Component({
selector: 'app-badge',
templateUrl: './badge.component.html',
styleUrls: ['./badge.component.less'],
standalone: true,
})
export class BadgeComponent {
@Input() public type: BadgeType = 'none';
}

View File

@@ -1,10 +1,8 @@
<button [disabled]="disabled" mat-button>
@if (icon) {
<span
><fa-icon [icon]="icon"></fa-icon><span class="content">&nbsp;</span></span
>
<span><fa-icon [icon]="icon"></fa-icon><span class="content">&nbsp;</span></span>
}
<span class="button-content">
<ng-content></ng-content>
</span>
</button>
</button>

View File

@@ -1,17 +1,11 @@
<div [class.fullscreen]="fullscreen" [class.padding]="padding" class="card">
@if (closeLink && !fullscreen) {
<button
[routerLink]="closeLink"
class="btn-close"
mat-icon-button
>
<button [routerLink]="closeLink" class="btn-close" mat-icon-button>
<fa-icon [icon]="closeIcon"></fa-icon>
</button>
}
@if (heading && !fullscreen) {
} @if (heading && !fullscreen) {
<div class="heading">{{ heading }}</div>
}
@if (subheading && !fullscreen) {
} @if (subheading && !fullscreen) {
<div class="subheading">{{ subheading }}</div>
}
<ng-content></ng-content>

View File

@@ -1,9 +1,5 @@
<div class="header">
<button
(click)="onFilterClick()"
[class.filter-active]="anyFilterActive"
mat-icon-button
>
<button (click)="onFilterClick()" [class.filter-active]="anyFilterActive" mat-icon-button>
<fa-icon [icon]="faFilter"></fa-icon>
</button>
<button mat-icon-button routerLink="new">
@@ -12,9 +8,9 @@
</div>
@if (filterVisible || anyFilterActive) {
<div @fade>
<div @fade>
<app-card>
<ng-content></ng-content>
</app-card>
</div>
</div>
}

View File

@@ -1,9 +1,4 @@
<svg
height="1024"
viewBox="0 0 270.93 270.93"
width="1024"
xmlns="http://www.w3.org/2000/svg"
>
<svg height="1024" viewBox="0 0 270.93 270.93" width="1024" xmlns="http://www.w3.org/2000/svg">
<defs></defs>
<g fill="#fff">
<path
@@ -13,10 +8,7 @@
d="m116.87 10.157-1.8882 4.7801-1.1823 4.7845-4.4402 0.29048-1.7967-4.5884-2.4969-4.4939-3.6268 0.47781-1.2476 4.9864-0.54906 4.8977-4.3633 0.86861-2.3806-4.3153-3.0618-4.1295-3.5339 0.94706-0.58683 5.1069 0.09583 4.9268-4.2121 1.4307-2.9239-3.9681-3.5745-3.6937-3.3799 1.4002 0.08404 5.1388 0.73936 4.8731-3.9914 1.9681-3.4163-3.5528-4.0248-3.1955-3.1693 1.8287 0.75527 5.0851 1.3683 4.7336-3.6995 2.4722-3.8506-3.0749-4.4083-2.6435-2.9035 2.2281 1.4118 4.9413 1.9739 4.5158-3.345 2.934-4.218-2.5462-4.7162-2.0466-2.5869 2.5883 2.0451 4.7147 2.5462 4.2195-2.934 3.345-4.5143-1.9739-4.9428-1.4118-2.2281 2.902 2.6435 4.4083 3.0763 3.8505-2.4721 3.6995-4.7351-1.3668-5.0837-0.75527-1.8301 3.1679 3.1969 4.0262 3.5513 3.4163-1.9681 3.9899-4.8716-0.73787-5.1403-0.08569-1.4002 3.3814 3.6951 3.5745 3.9667 2.9224-1.4307 4.2136-4.9268-0.09583-5.1069 0.58683-0.94699 3.5338 4.1294 3.0619 4.3153 2.3806-0.86853 4.3633-4.8963 0.54756-4.9864 1.2476-0.47788 3.6283 4.4925 2.4968 4.5899 1.7968-0.29196 4.4388-4.783 1.1838-4.7816 1.8882v3.6588l4.7816 1.8883 4.783 1.1823 0.29196 4.4402-4.5899 1.7967-4.4925 2.4954 0.47788 3.6283 4.9864 1.2476 4.8963 0.54905 0.86853 4.3632-4.3153 2.3806-4.1294 3.0618 0.94699 3.5338 5.1069 0.58686 4.9268-0.0958 1.4307 4.2122-3.9667 2.9239-3.6951 3.5745 1.4002 3.3799 5.1403-0.0857 4.8716-0.73787 1.9681 3.9914-3.5513 3.4162-3.1969 4.0249 1.8301 3.1693 5.0837-0.75535 4.7351-1.3682 2.4721 3.6994-3.0763 3.8505-2.6435 4.4083 2.2281 2.9035 4.9428-1.4133 4.5143-1.9739 2.934 3.345-2.5462 4.2195-2.0451 4.7162 2.5869 2.5868 4.7162-2.0451 4.218-2.5463 3.345 2.9326-1.9739 4.5158-1.4118 4.9428 2.9035 2.2281 4.4083-2.645 3.8506-3.0749 3.6995 2.4722-1.3683 4.7336-0.75527 5.0851 3.1693 1.8301 4.0248-3.1969 3.4163-3.5513 3.9914 1.9681-0.73936 4.8717-0.08404 5.1403 3.3799 1.4002 3.5745-3.6952 2.9239-3.9666 4.2121 1.4292-0.09583 4.9283 0.58683 5.1069 3.5339 0.94699 3.0618-4.1309 2.3806-4.3139 4.3633 0.8686 0.54906 4.8963 1.2476 4.9864 3.6268 0.47787 2.4969-4.494 1.7967-4.5884 4.4402 0.29189 1.1823 4.7831 1.8882 4.7816h3.6588l1.8882-4.7816 1.1823-4.7831 4.4402-0.29189 1.7967 4.5884 2.4969 4.494 3.6268-0.47787 1.2491-4.9864 0.54757-4.8963 4.3633-0.8686 2.3806 4.3139 3.0619 4.1309 3.5338-0.94699 0.58682-5.1069-0.0958-4.9283 4.2136-1.4292 2.9224 3.9666 3.5745 3.6952 3.3799-1.4002-0.0843-5.1403-0.73787-4.8717 3.9899-1.9681 3.4163 3.5513 4.0262 3.1969 3.1679-1.8301-0.75528-5.0851-1.3668-4.7336 3.6995-2.4722 3.8505 3.0749 4.4083 2.645 2.902-2.2281-1.4118-4.9428-1.9739-4.5158 3.345-2.9326 4.2194 2.5463 4.7148 2.0451 2.5883-2.5868-2.0465-4.7162-2.5462-4.2195 2.934-3.345 4.5157 1.9739 4.9414 1.4133 2.2281-2.9035-2.6435-4.4083-3.0749-3.8505 2.4707-3.6994 4.7351 1.3682 5.0837 0.75535 1.8301-3.1693-3.1969-4.0249-3.5513-3.4162 1.9681-3.9914 4.8716 0.73787 5.1403 0.0857 1.4002-3.3799-3.6951-3.5745-3.9668-2.9239 1.4307-4.2122 4.9268 0.0958 5.107-0.58686 0.94698-3.5338-4.1294-3.0618-4.3153-2.3806 0.86852-4.3632 4.8978-0.54905 4.9864-1.2476 0.47781-3.6283-4.4939-2.4954-4.5884-1.7967 0.29058-4.4402 4.7845-1.1823 4.7801-1.8883v-3.6588l-4.7801-1.8882-4.7845-1.1838-0.29058-4.4388 4.5884-1.7968 4.4939-2.4968-0.47781-3.6283-4.9864-1.2476-4.8978-0.54757-0.86852-4.3633 4.3153-2.3806 4.1294-3.0619-0.94698-3.5338-5.107-0.58683-4.9268 0.09583-1.4307-4.2136 3.9668-2.9224 3.6951-3.5745-1.4002-3.3814-5.1403 0.08569-4.8716 0.73787-1.9681-3.9899 3.5513-3.4163 3.1969-4.0262-1.8301-3.1679-5.0837 0.75527-4.7351 1.3668-2.4707-3.6995 3.0749-3.8505 2.6435-4.4083-2.2281-2.902-4.9414 1.4118-4.5157 1.9739-2.934-3.345 2.5462-4.2195 2.0465-4.7147-2.5883-2.5883-4.7148 2.0466-4.2194 2.5462-3.345-2.934 1.9739-4.5158 1.4118-4.9413-2.902-2.2281-4.4083 2.6435-3.8505 3.0749-3.6995-2.4722 1.3668-4.7336 0.75528-5.0851-3.1679-1.8287-4.0262 3.1955-3.4163 3.5528-3.9899-1.9681 0.73787-4.8731 0.0843-5.1388-3.3799-1.4002-3.5745 3.6937-2.9224 3.9681-4.2136-1.4307 0.0958-4.9268-0.58682-5.1069-3.5338-0.94706-3.0619 4.1295-2.3806 4.3153-4.3633-0.86861-0.54757-4.8977-1.2491-4.9864-3.6268-0.47781-2.4969 4.4939-1.7967 4.5884-4.4402-0.29048-1.1823-4.7845-1.8882-4.7801zm1.8301 21.667a96.677 96.677 0 0 1 96.677 96.677 96.677 96.677 0 0 1-96.677 96.677 96.677 96.677 0 0 1-96.677-96.677 96.677 96.677 0 0 1 96.677-96.677z"
id="gear-lg"
/>
<g
aria-label="WORSHIP GENERATOR"
transform="matrix(.92405 0 0 1.164 -6.4481 5.9862)"
>
<g aria-label="WORSHIP GENERATOR" transform="matrix(.92405 0 0 1.164 -6.4481 5.9862)">
<path
d="m26.155 213.81q-1.5697 4.1147-2.9258 8.0808-1.3468 3.9568-1.4118 4.1333h-1.2911q-0.04644-0.17647-1.0031-2.9537-0.9474-2.7865-2.5078-6.9291h-0.01858q-1.3654 3.4274-2.5171 6.5575-1.1425 3.1209-1.2168 3.3252h-1.3189q-0.07431-0.26936-1.1982-3.641-1.1146-3.3809-2.9815-8.4523l1.5233-0.59445q0.037153 0.13004 1.0867 3.2695 1.0496 3.1394 2.4057 7.0498h0.01858q1.4304-3.6038 2.4893-6.604 1.0682-3.0001 1.1146-3.1394h1.4397q0.03715 0.15791 1.1239 3.3995 1.096 3.2416 2.2199 6.3439h0.01858q1.4861-4.059 2.5078-7.1148 1.0217-3.0558 1.0589-3.2045z"
/>

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -1,81 +1,41 @@
@if (sections && !fullscreen) {
<div
(click)="onClick()"
[class.chords]="iChordMode !== 'hide'"
class="song-text"
>
<div (click)="onClick()" [class.chords]="iChordMode !== 'hide'" class="song-text">
@if (showSwitch) {
<button
(click)="onChordClick()"
class="menu"
mat-icon-button
>
<button (click)="onChordClick()" class="menu" mat-icon-button>
<fa-icon [icon]="faLines"></fa-icon>
</button>
}
<div [class.offset]="fullscreen" [style.top.px]="offset + 50">
@for (section of sections; track section; let i = $index) {
<div
#section
[class.chorus]="section.type === 1"
class="section"
>
@for (line of getLines(section); track line) {
<div
[class.chord]="line.type === 0"
[class.comment]="isComment(line.text)"
[class.disabled]="checkDisabled(i)"
class="line"
>
@for (section of sections; track section.type + '-' + section.number + '-' + $index; let i = $index) {
<div #section [class.chorus]="section.type === 1" class="section">
@for (line of getLines(section); track (line.lineNumber ?? $index) + '-' + line.type + '-' + $index) {
<div [class.chord]="line.type === 0" [class.comment]="isComment(line.text)" [class.disabled]="checkDisabled(i)" class="line">
@for (segment of getDisplaySegments(line); track $index) {
<span [class.invalid-chord-token]="segment.invalid" [class.invalid-tab-token]="segment.isTab">{{ segment.text }}</span>
}
</div>
}
</div>
}
@if (sections.length===0) {
<div class="error">
Es wurden keine Liedabschnitte gefunden! Bitte mindestens einen Abschnitt festlegen!
</div>
} @if (sections.length===0) {
<div class="error">Es wurden keine Liedabschnitte gefunden! Bitte mindestens einen Abschnitt festlegen!</div>
}
</div>
<ng-content></ng-content>
</div>
}
@if (sections && fullscreen) {
<div
(click)="onClick()"
[@songSwitch]="sections"
[class.chords]="iChordMode !== 'hide'"
class="song-text"
>
</div>
} @if (sections && fullscreen) {
<div (click)="onClick()" [@songSwitch]="sections" [class.chords]="iChordMode !== 'hide'" class="song-text">
@if (showSwitch) {
<button
(click)="onChordClick()"
class="menu"
mat-icon-button
>
<button (click)="onChordClick()" class="menu" mat-icon-button>
<fa-icon [icon]="faLines"></fa-icon>
</button>
}
<div [class.offset]="fullscreen" [style.top.px]="offset + 50">
@if (header) {
<h1>{{ header }}</h1>
}
@for (section of sections; track section; let i = $index) {
<div
#section
[class.chorus]="section.type === 1"
class="section"
>
@for (line of getLines(section); track line) {
<div
[class.chord]="line.type === 0"
[class.disabled]="checkDisabled(i)"
class="line"
>
} @for (section of sections; track section.type + '-' + section.number + '-' + $index; let i = $index) {
<div #section [class.chorus]="section.type === 1" class="section">
@for (line of getLines(section); track (line.lineNumber ?? $index) + '-' + line.type + '-' + $index) {
<div [class.chord]="line.type === 0" [class.disabled]="checkDisabled(i)" class="line">
@for (segment of getDisplaySegments(line, true); track $index) {
<span [class.invalid-chord-token]="segment.invalid" [class.invalid-tab-token]="segment.isTab">{{ segment.text }}</span>
}
@@ -85,5 +45,5 @@
}
</div>
<ng-content></ng-content>
</div>
</div>
}

View File

@@ -32,15 +32,15 @@ describe('RoleGuard', () => {
it('should deny access when there is no current user', done => {
guard.canActivate({data: {requiredRoles: ['leader']}} as never).subscribe(result => {
expect(result).toBeFalse();
expect(result).toEqual({commands: ['brand', 'new-user']} as never);
done();
});
});
it('should allow admins regardless of requiredRoles', async done => {
it('should allow admins regardless of requiredRoles', done => {
TestBed.resetTestingModule();
routerSpy = jasmine.createSpyObj<Router>('Router', ['createUrlTree']);
await TestBed.configureTestingModule({
TestBed.configureTestingModule({
providers: [
{provide: Router, useValue: routerSpy},
{provide: UserService, useValue: {user$: of({role: 'user;admin'})}},
@@ -54,10 +54,10 @@ describe('RoleGuard', () => {
});
});
it('should allow users with a matching required role', async done => {
it('should allow users with a matching required role', done => {
TestBed.resetTestingModule();
routerSpy = jasmine.createSpyObj<Router>('Router', ['createUrlTree']);
await TestBed.configureTestingModule({
TestBed.configureTestingModule({
providers: [
{provide: Router, useValue: routerSpy},
{provide: UserService, useValue: {user$: of({role: 'leader;user'})}},
@@ -71,11 +71,11 @@ describe('RoleGuard', () => {
});
});
it('should redirect users without the required role to their role default route', async done => {
it('should redirect users without the required role to their role default route', done => {
TestBed.resetTestingModule();
routerSpy = jasmine.createSpyObj<Router>('Router', ['createUrlTree']);
routerSpy.createUrlTree.and.returnValue({redirect: ['presentation']} as never);
await TestBed.configureTestingModule({
TestBed.configureTestingModule({
providers: [
{provide: Router, useValue: routerSpy},
{provide: UserService, useValue: {user$: of({role: 'presenter'})}},
@@ -89,4 +89,42 @@ describe('RoleGuard', () => {
done();
});
});
it('should redirect members to shows instead of new-user', done => {
TestBed.resetTestingModule();
routerSpy = jasmine.createSpyObj<Router>('Router', ['createUrlTree']);
routerSpy.createUrlTree.and.returnValue({redirect: ['shows']} as never);
TestBed.configureTestingModule({
providers: [
{provide: Router, useValue: routerSpy},
{provide: UserService, useValue: {user$: of({role: 'member'})}},
],
});
guard = TestBed.inject(RoleGuard);
guard.canActivate({data: {requiredRoles: ['user']}} as never).subscribe(result => {
expect(routerSpy.createUrlTree).toHaveBeenCalledWith(['shows']);
expect(result).toEqual({redirect: ['shows']} as never);
done();
});
});
it('should choose a matching default route from all assigned roles', done => {
TestBed.resetTestingModule();
routerSpy = jasmine.createSpyObj<Router>('Router', ['createUrlTree']);
routerSpy.createUrlTree.and.callFake(commands => ({redirect: commands}) as never);
TestBed.configureTestingModule({
providers: [
{provide: Router, useValue: routerSpy},
{provide: UserService, useValue: {user$: of({role: ' none ; leader '})}},
],
});
guard = TestBed.inject(RoleGuard);
guard.canActivate({data: {requiredRoles: ['presenter']}} as never).subscribe(result => {
expect(routerSpy.createUrlTree).toHaveBeenCalledWith(['shows']);
expect(result).toEqual({redirect: ['shows']} as never);
done();
});
});
});

View File

@@ -3,6 +3,7 @@ import {ActivatedRouteSnapshot, Router, UrlTree} from '@angular/router';
import {Observable} from 'rxjs';
import {UserService} from '../../services/user/user.service';
import {map, take} from 'rxjs/operators';
import {roles} from '../../services/user/roles';
@Injectable({
providedIn: 'root',
@@ -23,30 +24,45 @@ export class RoleGuard {
if (!user) {
return this.router.createUrlTree(['brand', 'new-user']);
}
const roles = user.role?.split(';') ?? [];
if (roles.indexOf('admin') !== -1) {
const userRoles = this.parseRoles(user.role);
if (userRoles.includes('admin')) {
return true;
}
const allowed = roles.some(s => requiredRoles.indexOf(s) !== -1);
const allowed = userRoles.some(role => requiredRoles.includes(role));
return allowed || this.router.createUrlTree(this.defaultRoute(roles));
return allowed || this.router.createUrlTree(this.defaultRoute(userRoles));
})
);
}
private defaultRoute(roles: string[]): string[] {
if (!roles || roles.length === 0) {
private defaultRoute(userRoles: roles[]): string[] {
if (userRoles.length === 0) {
return ['brand', 'new-user'];
}
switch (roles[0]) {
case 'user':
if (userRoles.includes('user') || userRoles.includes('contributor')) {
return ['songs'];
case 'presenter':
return ['presentation'];
case 'leader':
}
if (userRoles.includes('leader') || userRoles.includes('member')) {
return ['shows'];
}
return ['brand', 'new-user'];
if (userRoles.includes('presenter')) {
return ['presentation'];
}
return ['user', 'info'];
}
private parseRoles(role: string | null | undefined): roles[] {
if (!role) {
return [];
}
return role
.split(';')
.map(value => value.trim())
.filter((value): value is roles => value.length > 0 && value !== 'none');
}
}

View File

@@ -0,0 +1,15 @@
import {PublishedTypePipe} from './published-type.pipe';
describe('PublishedTypePipe', () => {
it('create an instance', () => {
const pipe = new PublishedTypePipe();
void expect(pipe).toBeTruthy();
});
it('should translate publication state', () => {
const pipe = new PublishedTypePipe();
expect(pipe.transform(true)).toBe('veröffentlicht');
expect(pipe.transform(false)).toBe('unveröffentlicht');
});
});

View File

@@ -0,0 +1,10 @@
import {Pipe, PipeTransform} from '@angular/core';
@Pipe({
name: 'publishedType',
})
export class PublishedTypePipe implements PipeTransform {
public transform(published: boolean): string {
return published ? 'veröffentlicht' : 'unveröffentlicht';
}
}

View File

@@ -0,0 +1,17 @@
import {ReportedTypePipe} from './reported-type.pipe';
describe('ReportedTypePipe', () => {
it('create an instance', () => {
const pipe = new ReportedTypePipe();
void expect(pipe).toBeTruthy();
});
it('should translate report states', () => {
const pipe = new ReportedTypePipe();
expect(pipe.transform('pending')).toBe('nicht gemeldet');
expect(pipe.transform('reported')).toBe('gemeldet');
expect(pipe.transform('not-required')).toBe('Meldung nicht notwendig');
expect(pipe.transform(null)).toBe('');
});
});

View File

@@ -0,0 +1,20 @@
import {Pipe, PipeTransform} from '@angular/core';
import {ReportedType} from '../../../modules/shows/services/show';
@Pipe({
name: 'reportedType',
})
export class ReportedTypePipe implements PipeTransform {
public transform(reportedType: ReportedType): string {
switch (reportedType) {
case 'pending':
return 'nicht gemeldet';
case 'reported':
return 'gemeldet';
case 'not-required':
return 'Meldung nicht notwendig';
default:
return '';
}
}
}

View File

@@ -4,15 +4,15 @@ import {Pipe, PipeTransform} from '@angular/core';
name: 'sortBy',
})
export class SortByPipe implements PipeTransform {
public transform(value: unknown[] | null, order: 'asc' | 'desc' = 'asc', column = ''): unknown[] | null {
public transform<T>(value: T[] | null, order: 'asc' | 'desc' = 'asc', column = ''): T[] | null {
if (!value || !order) {
return value;
} // no array
if (!column || column === '') {
if (order === 'asc') {
return value.sort();
return [...value].sort();
} else {
return value.sort().reverse();
return [...value].sort().reverse();
}
} // sort 1d array
if (value.length <= 1) {
@@ -26,7 +26,7 @@ export class SortByPipe implements PipeTransform {
});
}
private getComparableValue(item: unknown, column: string): string | number {
private getComparableValue<T>(item: T, column: string): string | number {
const value = (item as Record<string, unknown>)[column];
if (value instanceof Date) {
return value.getTime();

File diff suppressed because one or more lines are too long

View File

@@ -18,6 +18,7 @@ declare global {
interface Window {
wgeneratorAdmin?: {
rebuildSongUsage(): Promise<unknown>;
rebuildShowSongIds(): Promise<unknown>;
};
}
}
@@ -49,6 +50,17 @@ bootstrapApplication(AppComponent, {
const userService = appRef.injector.get(UserService);
window.wgeneratorAdmin = {
rebuildSongUsage: () => userService.rebuildSongUsage(),
rebuildShowSongIds: async () => {
console.info('[wgeneratorAdmin] rebuildShowSongIds started');
const result = await userService.rebuildShowSongIds(progress => {
console.info(`[wgeneratorAdmin] rebuildShowSongIds ${progress.processed}/${progress.total} shows processed`, {
showId: progress.showId,
showSongsProcessed: progress.showSongsProcessed,
});
});
console.info('[wgeneratorAdmin] rebuildShowSongIds finished', result);
return result;
},
};
})
.catch(err => console.error(err));

View File

@@ -6,6 +6,7 @@ import {BrowserDynamicTestingModule, platformBrowserDynamicTesting} from '@angul
import {provideNoopAnimations} from '@angular/platform-browser/animations';
import {ActivatedRoute, provideRouter} from '@angular/router';
import {BehaviorSubject, of} from 'rxjs';
import {Observable} from 'rxjs';
import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material/dialog';
import {provideNativeDateAdapter} from '@angular/material/core';
import {provideFirebaseApp, initializeApp} from '@angular/fire/app';
@@ -20,7 +21,7 @@ type req = {keys: () => {map: (context: req) => void}};
type TestingModuleDefinition = Parameters<typeof TestBed.configureTestingModule>[0];
type TestingProviderList = NonNullable<NonNullable<TestingModuleDefinition>['providers']>;
type CollectionStub = {
valueChanges: () => ReturnType<typeof of>;
valueChanges: () => Observable<unknown[]>;
add: () => Promise<{id: string}>;
};
type DocumentStub = {
@@ -78,7 +79,7 @@ const defaultTestingProviders: TestingProviderList = [
},
];
const originalConfigureTestingModule = TestBed.configureTestingModule.bind(TestBed) as typeof TestBed.configureTestingModule;
const originalConfigureTestingModule = TestBed.configureTestingModule.bind(TestBed);
const configureTestingModule: typeof TestBed.configureTestingModule = moduleDef => {
const extraProviders: TestingProviderList = moduleDef?.providers ?? [];
const mergedModuleDef: TestingModuleDefinition = moduleDef ? {...moduleDef} : {};

20
src/types/qrcode.d.ts vendored Normal file
View File

@@ -0,0 +1,20 @@
declare module 'qrcode' {
export interface QRCodeToDataURLOptions {
type?: string;
quality?: number;
width?: number;
height?: number;
color?: {
dark?: string;
light?: string;
};
}
export function toDataURL(text: string, options?: QRCodeToDataURLOptions): Promise<string>;
const QRCode: {
toDataURL: typeof toDataURL;
};
export default QRCode;
}