11 Commits

Author SHA1 Message Date
ecb25ee322 vitest implementation 2026-03-16 18:38:46 +01:00
2173ad6abf sidemenu shows 2026-03-16 18:16:19 +01:00
3bd359ee9e sidemenu song list 2026-03-16 17:24:10 +01:00
Benjamin Ifland
f9516bbc4d fix angular json 2026-03-16 16:02:57 +01:00
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
183 changed files with 5975 additions and 2679 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

@@ -15,8 +15,9 @@
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"outputPath": {
"base": "dist/wgenerator"
},
@@ -25,7 +26,7 @@
"src/polyfills.ts"
],
"tsConfig": "tsconfig.app.json",
"aot": false,
"inlineStyleLanguage": "less",
"assets": [
"src/browserconfig.xml",
"src/android-chrome-192x192.png",
@@ -49,17 +50,9 @@
"lodash",
"docx",
"qrcode"
],
"browser": "src/main.ts"
]
},
"configurations": {
"development": {
"aot": false,
"extractLicenses": false,
"sourceMap": true,
"optimization": false,
"namedChunks": true
},
"production": {
"fileReplacements": [
{
@@ -67,114 +60,55 @@
"with": "src/environments/environment.prod.ts"
}
],
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"namedChunks": false,
"aot": true,
"extractLicenses": true,
"budgets": [
{
"type": "initial",
"maximumWarning": "2mb",
"maximumError": "5mb"
"maximumWarning": "500kB",
"maximumError": "10MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "6kb",
"maximumError": "10kb"
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"serviceWorker": "ngsw-config.json"
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"buildTarget": "wgenerator:build:development"
},
"builder": "@angular/build:dev-server",
"configurations": {
"development": {
"buildTarget": "wgenerator:build:development"
},
"production": {
"buildTarget": "wgenerator:build:production"
},
"development": {
"buildTarget": "wgenerator:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"buildTarget": "wgenerator:build"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"builder": "@angular/build:unit-test",
"options": {
"main": "src/test.ts",
"polyfills": "src/polyfills.ts",
"runner": "vitest",
"tsConfig": "tsconfig.spec.json",
"karmaConfig": "karma.conf.js",
"assets": [
"src/favicon.ico",
"src/assets",
"src/manifest.webmanifest"
"setupFiles": [
"src/test-vitest.ts"
],
"styles": [],
"scripts": []
}
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": [
"src/**/*.ts",
"src/**/*.html"
]
"runnerConfig": true
}
}
}
}
},
"cli": {
"analytics": "4047dcd7-89f4-402f-958e-e365a5505c55",
"schematicCollections": [
"@angular-eslint/schematics"
]
},
"schematics": {
"@angular-eslint/schematics:application": {
"setParserOptionsProject": true
},
"@angular-eslint/schematics:library": {
"setParserOptionsProject": true
},
"@schematics/angular:component": {
"type": "component"
},
"@schematics/angular:directive": {
"type": "directive"
},
"@schematics/angular:service": {
"type": "service"
},
"@schematics/angular:guard": {
"typeSeparator": "."
},
"@schematics/angular:interceptor": {
"typeSeparator": "."
},
"@schematics/angular:module": {
"typeSeparator": "."
},
"@schematics/angular:pipe": {
"typeSeparator": "."
},
"@schematics/angular:resolver": {
"typeSeparator": "."
}
"analytics": false
}
}

View File

@@ -1,32 +0,0 @@
// Karma configuration file, see link for more information
// https://karma-runner.github.io/1.0/config/configuration-file.html
module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['jasmine', '@angular-devkit/build-angular'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage-istanbul-reporter'),
require('@angular-devkit/build-angular/plugins/karma')
],
client: {
clearContext: false // leave Jasmine Spec Runner output visible in browser
},
coverageIstanbulReporter: {
dir: require('path').join(__dirname, './coverage/wgenerator'),
reports: ['html', 'lcovonly', 'text-summary'],
fixWebpackSourcePaths: true
},
reporters: ['progress', 'kjhtml'],
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: ['Chrome'],
singleRun: false,
restartOnFileChange: true
});
};

3527
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -47,22 +47,15 @@
"@angular/cli": "^21.2.1",
"@angular/compiler-cli": "^21.2.2",
"@angular/language-service": "^21.2.2",
"@types/jasmine": "~6.0.0",
"@types/jasminewd2": "~2.0.13",
"@typescript-eslint/eslint-plugin": "^8.57.0",
"@typescript-eslint/parser": "^8.57.0",
"eslint": "^9.39.4",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
"firebase-tools": "^15.9.1",
"jasmine-core": "~6.1.0",
"jasmine-spec-reporter": "~7.0.0",
"karma": "~6.4.4",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage-istanbul-reporter": "~3.0.3",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "^2.2.0",
"jsdom": "^29.0.0",
"prettier": "^3.8.1",
"typescript": "~5.9.3"
"typescript": "~5.9.3",
"vitest": "^4.1.0"
}
}

View File

@@ -1,6 +1,5 @@
<app-navigation></app-navigation>
<div [@fader]="o.isActivated ? o.activatedRoute : ''" class="content">
<router-outlet #o="outlet"></router-outlet>
<div class="content">
<router-outlet></router-outlet>
</div>

View File

@@ -1,13 +1,13 @@
import {TestBed, waitForAsync} from '@angular/core/testing';
import {TestBed} from '@angular/core/testing';
import {RouterTestingModule} from '@angular/router/testing';
import {AppComponent} from './app.component';
describe('AppComponent', () => {
beforeEach(waitForAsync(() => {
void TestBed.configureTestingModule({
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [RouterTestingModule, AppComponent],
}).compileComponents();
}));
});
it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent);

View File

@@ -1,4 +1,4 @@
import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {BrandComponent} from './brand.component';
@@ -6,11 +6,11 @@ describe('BrandComponent', () => {
let component: BrandComponent;
let fixture: ComponentFixture<BrandComponent>;
beforeEach(waitForAsync(() => {
void TestBed.configureTestingModule({
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [BrandComponent],
}).compileComponents();
}));
});
beforeEach(() => {
fixture = TestBed.createComponent(BrandComponent);

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,4 +1,4 @@
import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {NewUserComponent} from './new-user.component';
@@ -6,11 +6,11 @@ describe('NewUserComponent', () => {
let component: NewUserComponent;
let fixture: ComponentFixture<NewUserComponent>;
beforeEach(waitForAsync(() => {
void TestBed.configureTestingModule({
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [NewUserComponent],
}).compileComponents();
}));
});
beforeEach(() => {
fixture = TestBed.createComponent(NewUserComponent);

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,4 +1,4 @@
import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {GuestComponent} from './guest.component';
@@ -6,11 +6,11 @@ describe('GuestComponent', () => {
let component: GuestComponent;
let fixture: ComponentFixture<GuestComponent>;
beforeEach(waitForAsync(() => {
void TestBed.configureTestingModule({
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [GuestComponent],
}).compileComponents();
}));
});
beforeEach(() => {
fixture = TestBed.createComponent(GuestComponent);

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>
} }

View File

@@ -1,4 +1,4 @@
import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {LegalComponent} from './legal.component';
@@ -6,11 +6,11 @@ describe('LegalComponent', () => {
let component: LegalComponent;
let fixture: ComponentFixture<LegalComponent>;
beforeEach(waitForAsync(() => {
void TestBed.configureTestingModule({
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [LegalComponent],
}).compileComponents();
}));
});
beforeEach(() => {
fixture = TestBed.createComponent(LegalComponent);

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,4 +1,4 @@
import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {LogoComponent} from './logo.component';
@@ -6,11 +6,11 @@ describe('LogoComponent', () => {
let component: LogoComponent;
let fixture: ComponentFixture<LogoComponent>;
beforeEach(waitForAsync(() => {
void TestBed.configureTestingModule({
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [LogoComponent],
}).compileComponents();
}));
});
beforeEach(() => {
fixture = TestBed.createComponent(LogoComponent);

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,4 +1,4 @@
import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {MonitorComponent} from './monitor.component';
@@ -6,11 +6,11 @@ describe('MonitorComponent', () => {
let component: MonitorComponent;
let fixture: ComponentFixture<MonitorComponent>;
beforeEach(waitForAsync(() => {
void TestBed.configureTestingModule({
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [MonitorComponent],
}).compileComponents();
}));
});
beforeEach(() => {
fixture = TestBed.createComponent(MonitorComponent);

View File

@@ -1,23 +1,13 @@
@if (show) {
<div @fade>
<app-card [closeIcon]="faIcon" [heading]="show.showType | showType"
[subheading]="show.date.toDate() | date:'dd.MM.yyyy'" closeLink="/presentation/select">
<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 +16,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 +30,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 +40,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 +61,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 +70,12 @@
<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>
}
}
</app-card>
</div>
} @if (show) {
<app-add-song [addedLive]="true" [showSongs]="showSongs" [show]="show" [songs]="songs$|async"></app-add-song>
} }
</app-card>
}

View File

@@ -1,4 +1,4 @@
import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {RemoteComponent} from './remote.component';
@@ -6,11 +6,11 @@ describe('RemoteComponent', () => {
let component: RemoteComponent;
let fixture: ComponentFixture<RemoteComponent>;
beforeEach(waitForAsync(() => {
void TestBed.configureTestingModule({
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [RemoteComponent],
}).compileComponents();
}));
});
beforeEach(() => {
fixture = TestBed.createComponent(RemoteComponent);

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

@@ -1,6 +1,6 @@
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {Router} from '@angular/router';
import {of} from 'rxjs';
import {firstValueFrom, of} from 'rxjs';
import {GlobalSettingsService} from '../../../services/global-settings.service';
import {ShowService} from '../../shows/services/show.service';
import {SelectComponent} from './select.component';
@@ -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();
@@ -51,12 +52,11 @@ describe('SelectComponent', () => {
expect(component.visible).toBeTrue();
});
it('should expose recent shows sorted descending by date', done => {
component.shows$.subscribe(shows => {
it('should expose recent shows sorted descending by date', async () => {
const shows = await firstValueFrom(component.shows$);
expect(showServiceSpy.list$).toHaveBeenCalledWith(true);
expect(shows.map(show => show.id)).toEqual(['recent-a', 'recent-b']);
done();
});
});
it('should persist the selected show, trigger presentation reset and navigate', async () => {

View File

@@ -5,8 +5,8 @@ import {PresentationService} from './presentation.service';
describe('PresentationService', () => {
let service: PresentationService;
beforeEach(() => {
void TestBed.configureTestingModule({});
beforeEach(async () => {
await TestBed.configureTestingModule({});
service = TestBed.inject(PresentationService);
});

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-soft);
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

@@ -2,4 +2,5 @@ export interface FilterValues {
time: number;
owner: string;
showType: string;
archived: boolean;
}

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,24 +25,18 @@
<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>
<mat-checkbox formControlName="archived">Archiviert</mat-checkbox>
</div>
<i>Anzahl der Suchergebnisse: {{ shows?.length ?? 0 }}</i>
<!-- <i>Anzahl der Suchergebnisse: {{ shows?.length ?? 0 }}</i>-->
</div>

View File

@@ -1,5 +1,14 @@
.third {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
column-gap: 20px;
.third,
div[formGroup] {
display: flex;
flex-direction: column;
gap: 12px;
}
.third {
gap: 0;
}
:host ::ng-deep .mat-mdc-form-field {
width: 100%;
}

View File

@@ -1,4 +1,4 @@
import {Component, DestroyRef, Input, inject} from '@angular/core';
import {Component, DestroyRef, inject, Input} from '@angular/core';
import {KeyValue} from '@angular/common';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
import {FormBuilder, FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms';
@@ -14,28 +14,23 @@ import {MatFormField, MatLabel} from '@angular/material/form-field';
import {MatSelect} from '@angular/material/select';
import {MatOptgroup, MatOption} from '@angular/material/core';
import {ShowTypePipe} from '../../../../widget-modules/pipes/show-type-translater/show-type.pipe';
import {MatCheckbox} from '@angular/material/checkbox';
@Component({
selector: 'app-filter',
templateUrl: './filter.component.html',
styleUrls: ['./filter.component.less'],
imports: [ReactiveFormsModule, MatFormField, MatLabel, MatSelect, MatOption, MatOptgroup, ShowTypePipe],
imports: [ReactiveFormsModule, MatFormField, MatLabel, MatSelect, MatOption, MatOptgroup, ShowTypePipe, MatCheckbox],
})
export class FilterComponent {
private showService = inject(ShowService);
private userService = inject(UserService);
private filterStore = inject(FilterStoreService);
private destroyRef = inject(DestroyRef);
@Input() public shows: Show[] = [];
public showTypePublic = ShowService.SHOW_TYPE_PUBLIC;
public showTypePrivate = ShowService.SHOW_TYPE_PRIVATE;
public filterFormGroup: FormGroup<{
time: FormControl<number>;
owner: FormControl<string | null>;
showType: FormControl<string | null>;
archived: FormControl<boolean>;
}>;
public times: KeyValue<number, string>[] = [
{key: 1, value: 'letzter Monat'},
@@ -43,8 +38,11 @@ export class FilterComponent {
{key: 12, value: 'letztes Jahr'},
{key: 99999, value: 'alle'},
];
public owners: {key: string; value: string}[] = [];
private showService = inject(ShowService);
private userService = inject(UserService);
private filterStore = inject(FilterStoreService);
private destroyRef = inject(DestroyRef);
public constructor() {
const fb = inject(FormBuilder);
@@ -53,6 +51,7 @@ export class FilterComponent {
time: fb.nonNullable.control(1),
owner: fb.control<string | null>(null),
showType: fb.control<string | null>(null),
archived: fb.nonNullable.control(false),
});
this.filterStore.showFilter$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(filterValues => {
@@ -61,6 +60,7 @@ export class FilterComponent {
time: filterValues.time,
owner: filterValues.owner || null,
showType: filterValues.showType || null,
archived: !!filterValues.archived,
},
{emitEvent: false}
);
@@ -69,6 +69,7 @@ export class FilterComponent {
this.filterFormGroup.controls.time.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => this.filterValueChanged('time', value));
this.filterFormGroup.controls.owner.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => this.filterValueChanged('owner', value ?? ''));
this.filterFormGroup.controls.showType.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => this.filterValueChanged('showType', value ?? ''));
this.filterFormGroup.controls.archived.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => this.filterValueChanged('archived', value));
this.owners$()
.pipe(takeUntilDestroyed(this.destroyRef))

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

@@ -1,42 +1,42 @@
@if (showSidebar$ | async) {
<app-sidebar>
<div class="sidebar-content" sidebar>
<app-filter [shows]="(publicShows$ | async) ?? []"></app-filter>
</div>
<div content>
@if (privateShows$ | async; as privateShows) {
<app-card [padding]="false" heading="Meine Veranstaltungen">
@for (show of privateShows; track trackBy($index, show)) {
<app-list-item
[routerLink]="show.id"
[showStatusBadgeType]="show.archived ? 'warn' : show.published ? 'error' : 'none'"
[showStatusBadge]="show.archived ? 'archiviert' : show.published ? 'nicht gemeldet' : 'unveröffentlicht'"
[show]="show"
></app-list-item>
}
<div *appRole="['leader']" class="list-action">
<app-button [fullWidth]="true" [icon]="faNewShow" routerLink="new">Neue Veranstaltung anlegen </app-button>
</div>
</app-card>
}
@if (publicShows$ | async; as shows) { @if (shows.length > 0) {
<app-card [padding]="false" heading="Veröffentlichte Veranstaltungen">
@for (show of shows; track trackBy($index, show)) {
<app-list-item [routerLink]="show.id" [show]="show"></app-list-item>
}
</app-card>
} }
</div>
</app-sidebar>
} @else {
<div>
<!-- <app-list-header *appRole="['leader']"></app-list-header>-->
<app-list-header *appRole="['leader']">
@if (shows$ | async; as shows) {
<app-filter [shows]="publicShows$ | async"></app-filter>
}
</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>
@if (publicShows$ | async; as shows) { @if (shows.length > 0) {
<app-card [padding]="false" heading="Veröffentlichte Veranstaltungen">
@for (show of shows; track trackBy($index, 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"
>
@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

@@ -0,0 +1,3 @@
.sidebar-content {
padding: 20px;
}

View File

@@ -1,24 +1,106 @@
import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {BehaviorSubject, firstValueFrom, of} from 'rxjs';
import {skip, take} from 'rxjs/operators';
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', async () => {
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);
const shows = await firstValueFrom(component.privateShows$);
expect(shows.map(show => show.id)).toEqual(['pending-own', 'draft-own']);
});
it('should ignore show filters for my shows', async () => {
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);
const shows = await firstValueFrom(component.privateShows$);
expect(shows.map(show => show.id)).toEqual(['pending-own', 'older-draft']);
});
it('should hide archived own shows until archived filter is enabled', async () => {
const filterStore = TestBed.inject(FilterStoreService);
shows$.next([
createShow({id: 'draft-own', owner: 'user-1', published: false, reportedType: null, date: {toDate: () => new Date('2026-03-02')}}),
createShow({id: 'archived-own', owner: 'user-1', published: true, archived: true, reportedType: 'reported', date: {toDate: () => new Date('2026-03-03')}}),
] as never);
const initialShows = await firstValueFrom(component.privateShows$.pipe(take(1)));
expect(initialShows.map(show => show.id)).toEqual(['draft-own']);
const updatedShowsPromise = firstValueFrom(component.privateShows$.pipe(skip(1), take(1)));
filterStore.updateShowFilter({archived: true});
const updatedShows = await updatedShowsPromise;
expect(updatedShows.map(show => show.id)).toEqual(['archived-own', 'draft-own']);
});
it('should sort public shows by date descending', async () => {
const filterStore = TestBed.inject(FilterStoreService);
filterStore.updateShowFilter({time: 99999});
shows$.next([
createShow({id: 'old-public', owner: 'user-2', published: true, archived: false, date: {toDate: () => new Date('2026-01-01')}}),
createShow({id: 'new-public', owner: 'user-3', published: true, archived: false, date: {toDate: () => new Date('2026-03-10')}}),
createShow({id: 'mid-public', owner: 'user-4', published: true, archived: false, date: {toDate: () => new Date('2026-02-05')}}),
] as never);
const shows = await firstValueFrom(component.publicShows$.pipe(take(1)));
expect(shows.map(show => show.id)).toEqual(['new-public', 'mid-public', 'old-public']);
});
});

View File

@@ -7,32 +7,51 @@ import {FilterValues} from './filter/filter-values';
import {RouterLink} from '@angular/router';
import {map, switchMap} from 'rxjs/operators';
import {FilterStoreService} from '../../../services/filter-store.service';
import {RoleDirective} from '../../../services/user/role.directive';
import {ListHeaderComponent} from '../../../widget-modules/components/list-header/list-header.component';
import {AsyncPipe} from '@angular/common';
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';
import {SidebarComponent} from '../../../widget-modules/components/sidebar/sidebar.component';
import {ButtonComponent} from '../../../widget-modules/components/button/button.component';
import {faPlus} from '@fortawesome/free-solid-svg-icons';
import {RoleDirective} from '../../../services/user/role.directive';
@Component({
selector: 'app-list',
templateUrl: './list.component.html',
styleUrls: ['./list.component.less'],
animations: [fade],
imports: [RoleDirective, ListHeaderComponent, FilterComponent, CardComponent, ListItemComponent, RouterLink, AsyncPipe, SortByPipe],
imports: [FilterComponent, CardComponent, ListItemComponent, RouterLink, AsyncPipe, SidebarComponent, ButtonComponent, RoleDirective],
})
export class ListComponent {
public faNewShow = faPlus;
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 archived$ = this.filter$.pipe(map((filterValues: FilterValues) => !!filterValues.archived));
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 ownShows$ = this.showService.list$(false, true);
public privateShows$ = combineLatest([this.ownShows$, this.userService.user$, this.archived$]).pipe(
map(([shows, user, showArchived]) =>
shows.filter(show => {
if (show.owner !== user?.id) {
return false;
}
if (show.archived) {
return showArchived;
}
return !show.published || show.reportedType === 'pending';
})
),
map(shows => this.sortShowsByDateDesc(shows))
);
public queriedPublicShows$ = this.lastMonths$.pipe(switchMap(lastMonths => this.showService.listPublicSince$(lastMonths)));
public fallbackPublicShows$ = combineLatest([this.shows$, this.lastMonths$]).pipe(
@@ -44,20 +63,30 @@ export class ListComponent {
map(([queriedShows, fallbackShows, owner, showType]) => {
const shows = queriedShows.length > 0 || fallbackShows.length === 0 ? queriedShows : fallbackShows;
return shows.filter(show => !owner || show.owner === owner).filter(show => !showType || show.showType === showType);
return this.sortShowsByDateDesc(shows.filter(show => !owner || show.owner === owner).filter(show => !showType || show.showType === showType));
})
);
public showSidebar$ = this.userService.user$.pipe(map(user => this.hasSidebarAccess(user?.role)));
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);
startDate.setDate(startDate.getDate() - lastMonths * 30);
return show.date.toDate() >= startDate;
}
private sortShowsByDateDesc(shows: Show[]): Show[] {
return [...shows].sort((left, right) => right.date.toDate().getTime() - left.date.toDate().getTime());
}
private hasSidebarAccess(role: string | null | undefined): boolean {
if (!role) {
return false;
}
const roles = role.split(';').map(item => item.trim());
return roles.includes('admin') || roles.includes('leader');
}
}

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

@@ -1,4 +1,4 @@
import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {NewComponent} from './new.component';
@@ -6,11 +6,11 @@ describe('NewComponent', () => {
let component: NewComponent;
let fixture: ComponentFixture<NewComponent>;
beforeEach(waitForAsync(() => {
void TestBed.configureTestingModule({
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [NewComponent],
}).compileComponents();
}));
});
beforeEach(() => {
fixture = TestBed.createComponent(NewComponent);

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

@@ -1,5 +1,5 @@
import {TestBed} from '@angular/core/testing';
import {BehaviorSubject, of} from 'rxjs';
import {BehaviorSubject, firstValueFrom, of} from 'rxjs';
import {ShowDataService} from './show-data.service';
import {ShowService} from './show.service';
import {UserService} from '../../../services/user/user.service';
@@ -8,6 +8,7 @@ describe('ShowService', () => {
let service: ShowService;
let showDataServiceSpy: jasmine.SpyObj<ShowDataService>;
let user$: BehaviorSubject<unknown>;
let shows$: BehaviorSubject<unknown[]>;
const shows = [
{id: 'show-1', owner: 'user-1', published: false, archived: false},
{id: 'show-2', owner: 'other-user', published: true, archived: false},
@@ -16,8 +17,9 @@ describe('ShowService', () => {
beforeEach(async () => {
user$ = new BehaviorSubject<unknown>({id: 'user-1'});
shows$ = new BehaviorSubject<unknown[]>(shows as unknown[]);
showDataServiceSpy = jasmine.createSpyObj<ShowDataService>('ShowDataService', ['read$', 'listPublicSince$', 'update', 'add'], {
list$: of(shows),
list$: shows$.asObservable() as unknown as ShowDataService['list$'],
});
showDataServiceSpy.read$.and.returnValue(of(shows[0]));
showDataServiceSpy.listPublicSince$.and.returnValue(of([shows[1]]));
@@ -38,34 +40,39 @@ describe('ShowService', () => {
expect(service).toBeTruthy();
});
it('should list published shows and own drafts, but exclude archived ones', done => {
service.list$().subscribe(result => {
it('should list published shows and own drafts, but exclude archived ones', async () => {
const result = await firstValueFrom(service.list$());
expect(result.map(show => show.id)).toEqual(['show-1', 'show-2']);
done();
});
});
it('should filter out private drafts when publishedOnly is true', done => {
service.list$(true).subscribe(result => {
it('should filter out private drafts when publishedOnly is true', async () => {
const result = await firstValueFrom(service.list$(true));
expect(result.map(show => show.id)).toEqual(['show-2']);
done();
});
});
it('should delegate public listing to the data service', done => {
service.listPublicSince$(6).subscribe(result => {
expect(result).toEqual([shows[1]]);
it('should include own archived shows when requested', async () => {
const result = await firstValueFrom(service.list$(false, true));
expect(result.map(show => show.id)).toEqual(['show-1', 'show-2', 'show-3']);
});
it('should not include archived shows from other users when requested', async () => {
shows$.next([
...(shows as unknown as unknown[]),
{id: 'show-4', owner: 'other-user', published: true, archived: true},
]);
const result = await firstValueFrom(service.list$(false, true));
expect(result.map(show => show.id)).toEqual(['show-1', 'show-2', 'show-3']);
});
it('should delegate public listing to the data service', async () => {
await expectAsync(firstValueFrom(service.listPublicSince$(6))).toBeResolvedTo([shows[1]]);
expect(showDataServiceSpy.listPublicSince$).toHaveBeenCalledWith(6);
done();
});
});
it('should delegate reads to the data service', done => {
service.read$('show-1').subscribe(result => {
expect(result).toEqual(shows[0]);
it('should delegate reads to the data service', async () => {
await expectAsync(firstValueFrom(service.read$('show-1'))).toBeResolvedTo(shows[0]);
expect(showDataServiceSpy.read$).toHaveBeenCalledWith('show-1');
done();
});
});
it('should delegate updates to the data service', async () => {
@@ -97,6 +104,7 @@ describe('ShowService', () => {
showType: type,
owner: 'user-1',
order: [],
songIds: [],
public: true,
});
});
@@ -111,6 +119,7 @@ describe('ShowService', () => {
showType: type,
owner: 'user-1',
order: [],
songIds: [],
public: false,
});
});

View File

@@ -20,13 +20,17 @@ export class ShowService {
public read$ = (showId: string): Observable<Show | null> => this.showDataService.read$(showId);
public listPublicSince$ = (lastMonths: number): Observable<Show[]> => this.showDataService.listPublicSince$(lastMonths);
public list$(publishedOnly = false): Observable<Show[]> {
public list$(publishedOnly = false, includeOwnArchived = false): Observable<Show[]> {
return this.userService.user$.pipe(
switchMap(
() => this.showDataService.list$,
(user: User | null, shows: Show[]) => ({user, shows})
),
map(s => s.shows.filter(show => !show.archived).filter(show => show.published || (show.owner === s.user?.id && !publishedOnly)))
map(s =>
s.shows
.filter(show => !show.archived || (includeOwnArchived && show.owner === s.user?.id))
.filter(show => show.published || (show.owner === s.user?.id && !publishedOnly))
)
);
}
@@ -39,6 +43,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;
@@ -52,7 +59,7 @@
}
.next-song {
color: var(--text-muted);
color: var(--text-soft);
position: fixed;
bottom: 0;
right: 10px;
@@ -63,7 +70,7 @@
}
.time {
color: var(--text-muted);
color: var(--text-soft);
position: fixed;
bottom: 0;
left: 10px;

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

@@ -1,4 +1,4 @@
import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {SongComponent} from './song.component';
@@ -6,11 +6,11 @@ describe('SongComponent', () => {
let component: SongComponent;
let fixture: ComponentFixture<SongComponent>;
beforeEach(waitForAsync(() => {
void TestBed.configureTestingModule({
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SongComponent],
}).compileComponents();
}));
});
beforeEach(() => {
fixture = TestBed.createComponent(SongComponent);

View File

@@ -4,6 +4,7 @@ import {NewComponent} from './new/new.component';
import {ListComponent} from './list/list.component';
import {ShowComponent} from './show/show.component';
import {EditComponent} from './edit/edit.component';
import {RoleGuard} from '../../widget-modules/guards/role.guard';
const routes: Routes = [
{
@@ -14,6 +15,10 @@ const routes: Routes = [
{
path: 'new',
component: NewComponent,
canActivate: [RoleGuard],
data: {
requiredRoles: ['leader'],
},
},
{
path: ':showId/edit',

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

@@ -1,5 +1,5 @@
import {TestBed} from '@angular/core/testing';
import {of} from 'rxjs';
import {firstValueFrom, of} from 'rxjs';
import {SongService} from './song.service';
import {SongListResolver} from './song-list.resolver';
@@ -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}],
@@ -22,11 +22,8 @@ describe('SongListResolver', () => {
expect(resolver).toBeTruthy();
});
it('should resolve the first emitted song list from the service', done => {
resolver.resolve().subscribe(songs => {
expect(songServiceSpy.list$).toHaveBeenCalled();
expect(songs).toEqual([{id: 'song-1', title: 'Amazing Grace'}] as never);
done();
});
it('should resolve the first emitted song list from the service', async () => {
await expectAsync(firstValueFrom(resolver.resolve())).toBeResolvedTo([{id: 'song-1', title: 'Amazing Grace'}] as never);
expect(songServiceSpy.listLoaded$).toHaveBeenCalled();
});
});

View File

@@ -1,5 +1,5 @@
import {TestBed} from '@angular/core/testing';
import {of} from 'rxjs';
import {firstValueFrom, of} from 'rxjs';
import {SongDataService} from './song-data.service';
import {SongService} from './song.service';
import {UserService} from '../../../services/user/user.service';
@@ -41,11 +41,8 @@ describe('SongService', () => {
expect(service).toBeTruthy();
});
it('should list songs from the data service', done => {
service.list$().subscribe(songs => {
expect(songs).toEqual([song]);
done();
});
it('should list songs from the data service', async () => {
await expectAsync(firstValueFrom(service.list$())).toBeResolvedTo([song]);
});
it('should delegate reads to the data service', async () => {

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

@@ -38,7 +38,7 @@ Bridge
Cool bridge without any chords
`;
beforeEach(() => void TestBed.configureTestingModule({}));
beforeEach(async () => await TestBed.configureTestingModule({}));
it('should be created', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
@@ -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', () => {
@@ -482,13 +478,13 @@ Text`;
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const text = 'Strophe\nC\tG\ta\nText';
void expect(service.validateChordNotation(text)).toContain(
void expect(service.validateChordNotation(text)).toEqual(expect.arrayContaining([
jasmine.objectContaining({
lineNumber: 2,
token: '\t',
reason: 'tab_character',
})
);
}),
]));
});
it('should not flag tabs on non chord lines', () => {

View File

@@ -7,8 +7,8 @@ import {Line} from './line';
describe('TransposeService', () => {
let service: TransposeService;
beforeEach(() => {
void TestBed.configureTestingModule({});
beforeEach(async () => {
await TestBed.configureTestingModule({});
service = TestBed.inject(TransposeService);
});

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,15 @@
.third {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
column-gap: 20px;
.third,
:host ::ng-deep form,
div[formGroup] {
display: flex;
flex-direction: column;
gap: 12px;
}
.third {
gap: 0;
}
:host ::ng-deep .mat-mdc-form-field {
width: 100%;
}

View File

@@ -1,4 +1,4 @@
import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {FilterComponent} from './filter.component';
@@ -6,11 +6,11 @@ describe('FilterComponent', () => {
let component: FilterComponent;
let fixture: ComponentFixture<FilterComponent>;
beforeEach(waitForAsync(() => {
void TestBed.configureTestingModule({
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [FilterComponent],
}).compileComponents();
}));
});
beforeEach(() => {
fixture = TestBed.createComponent(FilterComponent);

View File

@@ -1,8 +1,9 @@
@if (songs$ | async; as songs) {
<div>
<app-list-header [anyFilterActive]="anyFilterActive">
<app-sidebar>
<div sidebar class="sidebar-content">
<app-filter [songs]="songs"></app-filter>
</app-list-header>
</div>
<div content>
<app-card [padding]="false">
@for (song of songs; track trackBy($index, song)) {
<div [routerLink]="song.id" class="list-item">
@@ -19,13 +20,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>
@@ -40,6 +39,10 @@
<div>{{ song.key }}</div>
</div>
}
<div *appRole="['contributor']" class="list-action">
<app-button [fullWidth]="true" [icon]="faNewSong" routerLink="new">Neuen Song anlegen</app-button>
</div>
</app-card>
</div>
</app-sidebar>
}

View File

@@ -1,3 +1,7 @@
.sidebar-content {
padding: 20px;
}
.list-item {
padding: 5px 20px;
display: grid;

View File

@@ -1,27 +1,28 @@
import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';
import {ComponentFixture, TestBed} 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 songs = [{id: 'song-1', title: 'title1', number: 1, text: '', flags: ''}];
const mockSongService = {
list$: () => of(songs),
};
beforeEach(waitForAsync(() => {
void TestBed.configureTestingModule({
beforeEach(async () => {
await 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();
}));
});
beforeEach(() => {
fixture = TestBed.createComponent(SongListComponent);

View File

@@ -6,15 +6,16 @@ import {fade} from '../../../animations';
import {ActivatedRoute, RouterLink} from '@angular/router';
import {filterSong} from '../../../services/filter.helper';
import {FilterValues} from './filter/filter-values';
import {faBalanceScaleRight, faCheck, faPencilRuler} from '@fortawesome/free-solid-svg-icons';
import {faBalanceScaleRight, faCheck, faPencilRuler, faPlus} from '@fortawesome/free-solid-svg-icons';
import {TextRenderingService} from '../services/text-rendering.service';
import {FilterStoreService} from '../../../services/filter-store.service';
import {AsyncPipe} from '@angular/common';
import {ListHeaderComponent} from '../../../widget-modules/components/list-header/list-header.component';
import {FilterComponent} from './filter/filter.component';
import {CardComponent} from '../../../widget-modules/components/card/card.component';
import {RoleDirective} from '../../../services/user/role.directive';
import {FaIconComponent} from '@fortawesome/angular-fontawesome';
import {SidebarComponent} from '../../../widget-modules/components/sidebar/sidebar.component';
import {ButtonComponent} from '../../../widget-modules/components/button/button.component';
interface SongListItem extends Song {
hasChordValidationIssues: boolean;
@@ -26,20 +27,21 @@ interface SongListItem extends Song {
styleUrls: ['./song-list.component.less'],
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [fade],
imports: [ListHeaderComponent, FilterComponent, CardComponent, RouterLink, RoleDirective, FaIconComponent, AsyncPipe],
imports: [FilterComponent, CardComponent, RouterLink, RoleDirective, FaIconComponent, AsyncPipe, SidebarComponent, ButtonComponent],
})
export class SongListComponent {
public faLegal = faBalanceScaleRight;
public faDraft = faPencilRuler;
public faFinal = faCheck;
public faNewSong = faPlus;
private route = inject(ActivatedRoute);
private textRenderingService = inject(TextRenderingService);
private filterStore = inject(FilterStoreService);
public anyFilterActive = false;
public songs$: Observable<SongListItem[]> = combineLatest([
this.filterStore.songFilter$,
this.route.data.pipe(map(data => (data['songs'] as Song[]).slice().sort((a, b) => a.number - b.number))),
]).pipe(
map(([filter, songs]) => {
this.anyFilterActive = this.checkIfFilterActive(filter);
return songs
.filter(song => this.filter(song, filter))
.map(song => ({
@@ -49,9 +51,6 @@ export class SongListComponent {
.sort((a, b) => a.title?.localeCompare(b.title));
})
);
public faLegal = faBalanceScaleRight;
public faDraft = faPencilRuler;
public faFinal = faCheck;
public trackBy = (index: number, show: SongListItem) => show.id;
@@ -65,10 +64,6 @@ export class SongListComponent {
return baseFilter;
}
private checkIfFilterActive(filter: FilterValues): boolean {
return !!filter.q || !!filter.type || !!filter.key || !!filter.legalType || !!filter.flag;
}
private checkFlag(flag: string, flags: string) {
if (!flags) {
return false;

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,4 +1,4 @@
import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {EditFileComponent} from './edit-file.component';
@@ -6,11 +6,11 @@ describe('EditFileComponent', () => {
let component: EditFileComponent;
let fixture: ComponentFixture<EditFileComponent>;
beforeEach(waitForAsync(() => {
void TestBed.configureTestingModule({
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [EditFileComponent],
}).compileComponents();
}));
});
beforeEach(() => {
fixture = TestBed.createComponent(EditFileComponent);

View File

@@ -1,4 +1,4 @@
import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {FileComponent} from './file.component';
@@ -6,11 +6,11 @@ describe('FileComponent', () => {
let component: FileComponent;
let fixture: ComponentFixture<FileComponent>;
beforeEach(waitForAsync(() => {
void TestBed.configureTestingModule({
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [FileComponent],
}).compileComponents();
}));
});
beforeEach(() => {
fixture = TestBed.createComponent(FileComponent);

View File

@@ -4,8 +4,8 @@ import {EditSongGuard} from './edit-song.guard';
describe('EditSongGuard', () => {
let guard: EditSongGuard;
beforeEach(() => {
void TestBed.configureTestingModule({});
beforeEach(async () => {
await TestBed.configureTestingModule({});
guard = TestBed.inject(EditSongGuard);
});

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

@@ -1,4 +1,4 @@
import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {EditSongComponent} from './edit-song.component';
@@ -6,11 +6,11 @@ describe('EditSongComponent', () => {
let component: EditSongComponent;
let fixture: ComponentFixture<EditSongComponent>;
beforeEach(waitForAsync(() => {
void TestBed.configureTestingModule({
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [EditSongComponent],
}).compileComponents();
}));
});
beforeEach(() => {
fixture = TestBed.createComponent(EditSongComponent);

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,4 +1,4 @@
import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {SaveDialogComponent} from './save-dialog.component';
@@ -6,11 +6,11 @@ describe('SaveDialogComponent', () => {
let component: SaveDialogComponent;
let fixture: ComponentFixture<SaveDialogComponent>;
beforeEach(waitForAsync(() => {
void TestBed.configureTestingModule({
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SaveDialogComponent],
}).compileComponents();
}));
});
beforeEach(() => {
fixture = TestBed.createComponent(SaveDialogComponent);

View File

@@ -1,4 +1,4 @@
import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {EditComponent} from './edit.component';
@@ -6,11 +6,11 @@ describe('EditComponent', () => {
let component: EditComponent;
let fixture: ComponentFixture<EditComponent>;
beforeEach(waitForAsync(() => {
void TestBed.configureTestingModule({
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [EditComponent],
}).compileComponents();
}));
});
beforeEach(() => {
fixture = TestBed.createComponent(EditComponent);

View File

@@ -4,8 +4,8 @@ import {EditService} from './edit.service';
describe('EditService', () => {
let service: EditService;
beforeEach(() => {
void TestBed.configureTestingModule({});
beforeEach(async () => {
await TestBed.configureTestingModule({});
service = TestBed.inject(EditService);
});

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,4 +1,4 @@
import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {HistoryComponent} from './history.component';
@@ -6,11 +6,11 @@ describe('HistoryComponent', () => {
let component: HistoryComponent;
let fixture: ComponentFixture<HistoryComponent>;
beforeEach(waitForAsync(() => {
void TestBed.configureTestingModule({
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [HistoryComponent],
}).compileComponents();
}));
});
beforeEach(() => {
fixture = TestBed.createComponent(HistoryComponent);

View File

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

View File

@@ -1,4 +1,4 @@
import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {Storage} from '@angular/fire/storage';
import {FileComponent} from './file.component';
@@ -7,12 +7,12 @@ describe('FileComponent', () => {
let component: FileComponent;
let fixture: ComponentFixture<FileComponent>;
beforeEach(waitForAsync(() => {
void TestBed.configureTestingModule({
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [FileComponent],
providers: [{provide: Storage, useValue: {}}],
}).compileComponents();
}));
});
beforeEach(() => {
fixture = TestBed.createComponent(FileComponent);

View File

@@ -1,4 +1,4 @@
import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {NewComponent} from './new.component';
@@ -6,11 +6,11 @@ describe('NewComponent', () => {
let component: NewComponent;
let fixture: ComponentFixture<NewComponent>;
beforeEach(waitForAsync(() => {
void TestBed.configureTestingModule({
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [NewComponent],
}).compileComponents();
}));
});
beforeEach(() => {
fixture = TestBed.createComponent(NewComponent);

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

@@ -1,8 +1,13 @@
import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';
import {ComponentFixture, TestBed} 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;
@@ -12,12 +17,34 @@ describe('SongComponent', () => {
params: of({songId: '4711'}),
};
beforeEach(waitForAsync(() => {
void TestBed.configureTestingModule({
beforeEach(async () => {
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'}));
await 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();
}));
});
beforeEach(() => {
fixture = TestBed.createComponent(SongComponent);

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

@@ -6,6 +6,7 @@ import {EditComponent} from './song/edit/edit.component';
import {NewComponent} from './song/new/new.component';
import {EditSongGuard} from './song/edit/edit-song.guard';
import {SongListResolver} from './services/song-list.resolver';
import {RoleGuard} from '../../widget-modules/guards/role.guard';
const routes: Routes = [
{
@@ -19,6 +20,10 @@ const routes: Routes = [
{
path: 'new',
component: NewComponent,
canActivate: [RoleGuard],
data: {
requiredRoles: ['contributor'],
},
},
{
path: ':songId/edit',

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

@@ -1,4 +1,4 @@
import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {InfoComponent} from './info.component';
@@ -6,11 +6,11 @@ describe('InfoComponent', () => {
let component: InfoComponent;
let fixture: ComponentFixture<InfoComponent>;
beforeEach(waitForAsync(() => {
void TestBed.configureTestingModule({
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [InfoComponent],
}).compileComponents();
}));
});
beforeEach(() => {
fixture = TestBed.createComponent(InfoComponent);

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>
}

Some files were not shown because too many files have changed in this diff Show More