sidemenu shows

This commit is contained in:
2026-03-16 18:16:19 +01:00
parent 3bd359ee9e
commit 2173ad6abf
18 changed files with 226 additions and 59 deletions

View File

@@ -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": []
}
}
}
}

View File

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

View File

@@ -4,7 +4,7 @@
<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>
@@ -14,7 +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>
@@ -25,17 +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,31 +1,42 @@
<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) {
@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 shows | sortBy: 'desc':'date'; track trackBy($index, show)) {
@for (show of privateShows; track trackBy($index, show)) {
<app-list-item
[routerLink]="show.id"
[showStatusBadge]="show.published ? 'nicht gemeldet' : 'unveröffentlicht'"
[showStatusBadgeType]="show.published ? 'error' : 'none'"
[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>
} }
</ng-container>
</div>
</app-sidebar>
} @else {
<div>
@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)) {
@for (show of shows; 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,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();
});
});

View File

@@ -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');
}
}

View File

@@ -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) 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]]);

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

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

@@ -2,9 +2,6 @@
<app-sidebar>
<div sidebar class="sidebar-content">
<app-filter [songs]="songs"></app-filter>
<div class="sidebar-actions">
<app-button [icon]="faNewSong" routerLink="new">Neuen Song anlegen</app-button>
</div>
</div>
<div content>
<app-card [padding]="false">
@@ -42,6 +39,9 @@
<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,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 {

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

@@ -15,6 +15,7 @@ const DEFAULT_SHOW_FILTER: ShowFilterValues = {
time: 1,
owner: '',
showType: '',
archived: false,
};
@Injectable({

View File

@@ -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);
}

View File

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