diff --git a/angular.json b/angular.json index f39ca94..f33b725 100644 --- a/angular.json +++ b/angular.json @@ -95,7 +95,36 @@ "defaultConfiguration": "development" }, "test": { - "builder": "@angular/build:unit-test" + "builder": "@angular-devkit/build-angular:karma", + "options": { + "main": "src/test.ts", + "polyfills": [ + "src/polyfills.ts", + "zone.js/testing" + ], + "tsConfig": "tsconfig.spec.json", + "karmaConfig": "karma.conf.js", + "inlineStyleLanguage": "less", + "assets": [ + "src/browserconfig.xml", + "src/android-chrome-192x192.png", + "src/apple-touch-icon.png", + "src/apple-touch-icon-precomposed.png", + "src/safari-pinned-tab.svg", + "src/favicon.ico", + "src/favicon-16x16.png", + "src/favicon-32x32.png", + "src/mstile-150x150.png", + "src/assets", + "src/manifest.webmanifest" + ], + "styles": [ + "src/custom-theme.scss", + "src/styles/styles.less", + "src/styles/shadow.less" + ], + "scripts": [] + } } } } diff --git a/src/app/modules/shows/list/filter/filter-values.ts b/src/app/modules/shows/list/filter/filter-values.ts index 3b1f95d..e51e2a4 100644 --- a/src/app/modules/shows/list/filter/filter-values.ts +++ b/src/app/modules/shows/list/filter/filter-values.ts @@ -2,4 +2,5 @@ export interface FilterValues { time: number; owner: string; showType: string; + archived: boolean; } diff --git a/src/app/modules/shows/list/filter/filter.component.html b/src/app/modules/shows/list/filter/filter.component.html index 01ab7b3..672e96d 100644 --- a/src/app/modules/shows/list/filter/filter.component.html +++ b/src/app/modules/shows/list/filter/filter.component.html @@ -4,7 +4,7 @@ Zeitraum @for (time of times; track time) { - {{ time.value }} + {{ time.value }} } @@ -14,7 +14,7 @@ Alle @for (owner of owners; track owner) { - {{ owner.value }} + {{ owner.value }} } @@ -25,17 +25,18 @@ Alle @for (key of showTypePublic; track key) { - {{ key | showType }} + {{ key | showType }} } @for (key of showTypePrivate; track key) { - {{ key | showType }} + {{ key | showType }} } + Archiviert - Anzahl der Suchergebnisse: {{ shows?.length ?? 0 }} + diff --git a/src/app/modules/shows/list/filter/filter.component.less b/src/app/modules/shows/list/filter/filter.component.less index b172b2e..35af625 100644 --- a/src/app/modules/shows/list/filter/filter.component.less +++ b/src/app/modules/shows/list/filter/filter.component.less @@ -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%; } diff --git a/src/app/modules/shows/list/filter/filter.component.ts b/src/app/modules/shows/list/filter/filter.component.ts index 2a31db2..157170c 100644 --- a/src/app/modules/shows/list/filter/filter.component.ts +++ b/src/app/modules/shows/list/filter/filter.component.ts @@ -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; owner: FormControl; showType: FormControl; + archived: FormControl; }>; public times: KeyValue[] = [ {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(null), showType: fb.control(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)) diff --git a/src/app/modules/shows/list/list.component.html b/src/app/modules/shows/list/list.component.html index fd11a10..c3cb06a 100644 --- a/src/app/modules/shows/list/list.component.html +++ b/src/app/modules/shows/list/list.component.html @@ -1,31 +1,42 @@ -
- - - @if (shows$ | async; as shows) { - - } - - - - @if (privateShows$ | async; as shows) { @if (shows.length > 0) { +@if (showSidebar$ | async) { + + +
+ @if (privateShows$ | async; as privateShows) { - @for (show of shows | sortBy: 'desc':'date'; track trackBy($index, show)) { + @for (show of privateShows; track trackBy($index, show)) { } +
+ Neue Veranstaltung anlegen +
+
+ } + + @if (publicShows$ | async; as shows) { @if (shows.length > 0) { + + @for (show of shows; track trackBy($index, show)) { + + } } } - - +
+
+} @else { +
@if (publicShows$ | async; as shows) { @if (shows.length > 0) { - @for (show of shows | sortBy: 'desc':'date'; track trackBy($index, show)) { + @for (show of shows; track trackBy($index, show)) { } } }
+} diff --git a/src/app/modules/shows/list/list.component.less b/src/app/modules/shows/list/list.component.less index e69de29..e93d06d 100644 --- a/src/app/modules/shows/list/list.component.less +++ b/src/app/modules/shows/list/list.component.less @@ -0,0 +1,3 @@ +.sidebar-content { + padding: 20px; +} diff --git a/src/app/modules/shows/list/list.component.spec.ts b/src/app/modules/shows/list/list.component.spec.ts index 486bb2d..5ece736 100644 --- a/src/app/modules/shows/list/list.component.spec.ts +++ b/src/app/modules/shows/list/list.component.spec.ts @@ -1,5 +1,6 @@ import {ComponentFixture, TestBed} from '@angular/core/testing'; import {BehaviorSubject, 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'; @@ -59,7 +60,7 @@ describe('ListComponent', () => { ] as never); component.privateShows$.subscribe(shows => { - expect(shows.map(show => show.id)).toEqual(['draft-own', 'pending-own']); + expect(shows.map(show => show.id)).toEqual(['pending-own', 'draft-own']); done(); }); }); @@ -73,7 +74,41 @@ describe('ListComponent', () => { ] as never); component.privateShows$.subscribe(shows => { - expect(shows.map(show => show.id)).toEqual(['older-draft', 'pending-own']); + expect(shows.map(show => show.id)).toEqual(['pending-own', 'older-draft']); + done(); + }); + }); + + it('should hide archived own shows until archived filter is enabled', done => { + 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); + + component.privateShows$.pipe(take(1)).subscribe(shows => { + expect(shows.map(show => show.id)).toEqual(['draft-own']); + + component.privateShows$.pipe(skip(1), take(1)).subscribe(updatedShows => { + expect(updatedShows.map(show => show.id)).toEqual(['archived-own', 'draft-own']); + done(); + }); + + filterStore.updateShowFilter({archived: true}); + }); + }); + + it('should sort public shows by date descending', done => { + 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); + + component.publicShows$.pipe(take(1)).subscribe(shows => { + expect(shows.map(show => show.id)).toEqual(['new-public', 'mid-public', 'old-public']); done(); }); }); diff --git a/src/app/modules/shows/list/list.component.ts b/src/app/modules/shows/list/list.component.ts index 81cfa85..df26186 100644 --- a/src/app/modules/shows/list/list.component.ts +++ b/src/app/modules/shows/list/list.component.ts @@ -7,23 +7,25 @@ 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); @@ -32,9 +34,24 @@ export class ListComponent { 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.userService.user$]).pipe( - map(([shows, user]) => shows.filter(show => show.owner === user?.id).filter(show => !show.published || show.reportedType === 'pending')) + 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( @@ -46,9 +63,10 @@ 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; @@ -58,4 +76,17 @@ export class ListComponent { 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'); + } } diff --git a/src/app/modules/shows/services/show.service.spec.ts b/src/app/modules/shows/services/show.service.spec.ts index 84a7892..09994a3 100644 --- a/src/app/modules/shows/services/show.service.spec.ts +++ b/src/app/modules/shows/services/show.service.spec.ts @@ -8,6 +8,7 @@ describe('ShowService', () => { let service: ShowService; let showDataServiceSpy: jasmine.SpyObj; let user$: BehaviorSubject; + let shows$: BehaviorSubject; 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({id: 'user-1'}); + shows$ = new BehaviorSubject(shows as unknown[]); showDataServiceSpy = jasmine.createSpyObj('ShowDataService', ['read$', 'listPublicSince$', 'update', 'add'], { - list$: of(shows) as unknown as ShowDataService['list$'], + list$: shows$.asObservable() as unknown as ShowDataService['list$'], }); showDataServiceSpy.read$.and.returnValue(of(shows[0])); showDataServiceSpy.listPublicSince$.and.returnValue(of([shows[1]])); @@ -52,6 +54,25 @@ describe('ShowService', () => { }); }); + it('should include own archived shows when requested', done => { + service.list$(false, true).subscribe(result => { + expect(result.map(show => show.id)).toEqual(['show-1', 'show-2', 'show-3']); + done(); + }); + }); + + it('should not include archived shows from other users when requested', done => { + shows$.next([ + ...(shows as unknown as unknown[]), + {id: 'show-4', owner: 'other-user', published: true, archived: true}, + ]); + + service.list$(false, true).subscribe(result => { + expect(result.map(show => show.id)).toEqual(['show-1', 'show-2', 'show-3']); + done(); + }); + }); + it('should delegate public listing to the data service', done => { service.listPublicSince$(6).subscribe(result => { expect(result).toEqual([shows[1]]); diff --git a/src/app/modules/shows/services/show.service.ts b/src/app/modules/shows/services/show.service.ts index 63e4581..b3b8ac9 100644 --- a/src/app/modules/shows/services/show.service.ts +++ b/src/app/modules/shows/services/show.service.ts @@ -20,13 +20,17 @@ export class ShowService { public read$ = (showId: string): Observable => this.showDataService.read$(showId); public listPublicSince$ = (lastMonths: number): Observable => this.showDataService.listPublicSince$(lastMonths); - public list$(publishedOnly = false): Observable { + public list$(publishedOnly = false, includeOwnArchived = false): Observable { 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)) + ) ); } diff --git a/src/app/modules/shows/shows-routing.module.ts b/src/app/modules/shows/shows-routing.module.ts index 000e20b..f93559e 100644 --- a/src/app/modules/shows/shows-routing.module.ts +++ b/src/app/modules/shows/shows-routing.module.ts @@ -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', diff --git a/src/app/modules/songs/song-list/song-list.component.html b/src/app/modules/songs/song-list/song-list.component.html index 5bf5028..dbe364b 100644 --- a/src/app/modules/songs/song-list/song-list.component.html +++ b/src/app/modules/songs/song-list/song-list.component.html @@ -2,9 +2,6 @@
@@ -42,6 +39,9 @@
{{ song.key }}
} +
+ Neuen Song anlegen +
diff --git a/src/app/modules/songs/song-list/song-list.component.less b/src/app/modules/songs/song-list/song-list.component.less index 2773d68..20176be 100644 --- a/src/app/modules/songs/song-list/song-list.component.less +++ b/src/app/modules/songs/song-list/song-list.component.less @@ -1,13 +1,5 @@ .sidebar-content { padding: 20px; - height: 100%; - box-sizing: border-box; - display: flex; - flex-direction: column; -} - -.sidebar-actions { - margin-top: auto; } .list-item { diff --git a/src/app/modules/songs/songs-routing.module.ts b/src/app/modules/songs/songs-routing.module.ts index 6ae4cef..dc0a066 100644 --- a/src/app/modules/songs/songs-routing.module.ts +++ b/src/app/modules/songs/songs-routing.module.ts @@ -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', diff --git a/src/app/services/filter-store.service.ts b/src/app/services/filter-store.service.ts index c9bc707..1895f48 100644 --- a/src/app/services/filter-store.service.ts +++ b/src/app/services/filter-store.service.ts @@ -15,6 +15,7 @@ const DEFAULT_SHOW_FILTER: ShowFilterValues = { time: 1, owner: '', showType: '', + archived: false, }; @Injectable({ diff --git a/src/app/widget-modules/components/button/button.component.less b/src/app/widget-modules/components/button/button.component.less index 8059cdd..87716af 100644 --- a/src/app/widget-modules/components/button/button.component.less +++ b/src/app/widget-modules/components/button/button.component.less @@ -1,7 +1,21 @@ +:host { + display: inline-flex; +} + +:host(.full-width) { + display: flex; + width: 100%; +} + button { color: var(--text); transition: var(--transition); + :host(.full-width) & { + width: 100%; + justify-content: center; + } + &:hover { color: var(--primary-active); } diff --git a/src/app/widget-modules/components/button/button.component.ts b/src/app/widget-modules/components/button/button.component.ts index 8687025..fe9b5d0 100644 --- a/src/app/widget-modules/components/button/button.component.ts +++ b/src/app/widget-modules/components/button/button.component.ts @@ -9,8 +9,12 @@ import {FaIconComponent} from '@fortawesome/angular-fontawesome'; templateUrl: './button.component.html', styleUrls: ['./button.component.less'], imports: [MatButton, FaIconComponent], + host: { + '[class.full-width]': 'fullWidth', + }, }) export class ButtonComponent { @Input() public disabled = false; + @Input() public fullWidth = false; @Input() public icon: IconProp | null = null; }