From ec13ab56e8846fe3376db64565ce2a0d9c9bd0c3 Mon Sep 17 00:00:00 2001 From: Sean Date: Thu, 13 Aug 2020 17:41:43 +0200 Subject: [PATCH] Implement cinema mode Implements a viewer mode containing the most important information and Heavily refactors ListOfSpeaker and CurrentListOfSpeaker interaction heavy components on a single view: Current List of Speakers Currently Open Polls Current projector Permission in migration --- .../core/core-services/app-load.service.ts | 4 +- .../core/core-services/operator.service.ts | 1 + .../core/core-services/projector.service.ts | 10 +- .../projector/projector-repository.service.ts | 19 +- ...ssignment-poll-detail-content.component.ts | 2 +- .../list-of-speakers-content.component.html | 171 ++++++++ .../list-of-speakers-content.component.scss | 125 ++++++ ...list-of-speakers-content.component.spec.ts | 26 ++ .../list-of-speakers-content.component.ts | 344 +++++++++++++++ .../pipes/poll-percent-base.pipe.spec.ts | 2 +- .../shared/pipes/poll-percent-base.pipe.ts | 2 +- client/src/app/shared/shared.module.ts | 7 +- .../list-of-speakers.component.html | 177 +------- .../list-of-speakers.component.scss | 125 ------ .../list-of-speakers.component.ts | 412 ++---------------- .../assignments/assignments-routing.module.ts | 6 +- .../site/assignments/assignments.module.ts | 17 +- .../assignment-detail.component.spec.ts | 4 +- .../assignment-detail.component.ts | 4 +- .../assignment-poll-routing.module.ts | 12 + .../assignment-poll/assignment-poll.module.ts | 22 + ...ment-poll-detail-component.scss-theme.scss | 0 .../assignment-poll-detail.component.html | 0 .../assignment-poll-detail.component.scss | 0 .../assignment-poll-detail.component.spec.ts | 0 .../assignment-poll-detail.component.ts | 2 +- .../assignment-poll-dialog.component.html | 0 .../assignment-poll-dialog.component.scss | 0 .../assignment-poll-dialog.component.spec.ts | 0 .../assignment-poll-dialog.component.ts | 4 +- .../assignment-poll-vote.component.html | 0 .../assignment-poll-vote.component.scss | 0 .../assignment-poll-vote.component.spec.ts | 0 .../assignment-poll-vote.component.ts | 2 +- .../assignment-poll.component.html | 0 .../assignment-poll.component.scss | 0 .../assignment-poll.component.spec.ts | 0 .../assignment-poll.component.ts | 2 +- .../assignment-poll-dialog.service.spec.ts | 0 .../assignment-poll-dialog.service.ts | 4 +- .../assignment-poll-pdf.service.spec.ts | 0 .../services/assignment-poll-pdf.service.ts | 2 +- .../services/assignment-poll.service.spec.ts | 0 .../services/assignment-poll.service.ts | 4 +- .../services/assignment-pdf.service.ts | 2 +- .../app/site/cinema/cinema-routing.module.ts | 12 + client/src/app/site/cinema/cinema.config.ts | 16 + client/src/app/site/cinema/cinema.module.ts | 15 + .../components/cinema/cinema.component.html | 45 ++ .../components/cinema/cinema.component.scss | 3 + .../cinema/cinema.component.spec.ts | 27 ++ .../components/cinema/cinema.component.ts | 113 +++++ .../poll-collection.component.html | 13 + .../poll-collection.component.scss | 0 .../poll-collection.component.spec.ts | 27 ++ .../poll-collection.component.ts | 72 +++ .../motion-poll-vote.component.html | 3 - .../modules/motion-poll/motion-poll.module.ts | 2 +- .../motion-poll/motion-poll.component.html | 3 + .../current-list-of-speakers.service.ts | 17 +- client/src/app/site/site-routing.module.ts | 5 + client/src/app/site/site.component.html | 2 +- client/src/app/site/site.component.ts | 8 +- .../assignment-poll-slide.component.ts | 2 +- .../src/assets/styles/component-themes.scss | 2 +- .../styles/global-components-style.scss | 7 + client/src/styles.scss | 9 + .../migrations/0035_autopilot_permission.py | 57 +++ server/openslides/core/models.py | 1 + server/openslides/users/signals.py | 2 + 70 files changed, 1238 insertions(+), 739 deletions(-) create mode 100644 client/src/app/shared/components/list-of-speakers-content/list-of-speakers-content.component.html create mode 100644 client/src/app/shared/components/list-of-speakers-content/list-of-speakers-content.component.scss create mode 100644 client/src/app/shared/components/list-of-speakers-content/list-of-speakers-content.component.spec.ts create mode 100644 client/src/app/shared/components/list-of-speakers-content/list-of-speakers-content.component.ts create mode 100644 client/src/app/site/assignments/modules/assignment-poll/assignment-poll-routing.module.ts create mode 100644 client/src/app/site/assignments/modules/assignment-poll/assignment-poll.module.ts rename client/src/app/site/assignments/{ => modules/assignment-poll}/components/assignment-poll-detail/assignment-poll-detail-component.scss-theme.scss (100%) rename client/src/app/site/assignments/{ => modules/assignment-poll}/components/assignment-poll-detail/assignment-poll-detail.component.html (100%) rename client/src/app/site/assignments/{ => modules/assignment-poll}/components/assignment-poll-detail/assignment-poll-detail.component.scss (100%) rename client/src/app/site/assignments/{ => modules/assignment-poll}/components/assignment-poll-detail/assignment-poll-detail.component.spec.ts (100%) rename client/src/app/site/assignments/{ => modules/assignment-poll}/components/assignment-poll-detail/assignment-poll-detail.component.ts (98%) rename client/src/app/site/assignments/{ => modules/assignment-poll}/components/assignment-poll-dialog/assignment-poll-dialog.component.html (100%) rename client/src/app/site/assignments/{ => modules/assignment-poll}/components/assignment-poll-dialog/assignment-poll-dialog.component.scss (100%) rename client/src/app/site/assignments/{ => modules/assignment-poll}/components/assignment-poll-dialog/assignment-poll-dialog.component.spec.ts (100%) rename client/src/app/site/assignments/{ => modules/assignment-poll}/components/assignment-poll-dialog/assignment-poll-dialog.component.ts (98%) rename client/src/app/site/assignments/{ => modules/assignment-poll}/components/assignment-poll-vote/assignment-poll-vote.component.html (100%) rename client/src/app/site/assignments/{ => modules/assignment-poll}/components/assignment-poll-vote/assignment-poll-vote.component.scss (100%) rename client/src/app/site/assignments/{ => modules/assignment-poll}/components/assignment-poll-vote/assignment-poll-vote.component.spec.ts (100%) rename client/src/app/site/assignments/{ => modules/assignment-poll}/components/assignment-poll-vote/assignment-poll-vote.component.ts (98%) rename client/src/app/site/assignments/{ => modules/assignment-poll}/components/assignment-poll/assignment-poll.component.html (100%) rename client/src/app/site/assignments/{ => modules/assignment-poll}/components/assignment-poll/assignment-poll.component.scss (100%) rename client/src/app/site/assignments/{ => modules/assignment-poll}/components/assignment-poll/assignment-poll.component.spec.ts (100%) rename client/src/app/site/assignments/{ => modules/assignment-poll}/components/assignment-poll/assignment-poll.component.ts (97%) rename client/src/app/site/assignments/{ => modules/assignment-poll}/services/assignment-poll-dialog.service.spec.ts (100%) rename client/src/app/site/assignments/{ => modules/assignment-poll}/services/assignment-poll-dialog.service.ts (79%) rename client/src/app/site/assignments/{ => modules/assignment-poll}/services/assignment-poll-pdf.service.spec.ts (100%) rename client/src/app/site/assignments/{ => modules/assignment-poll}/services/assignment-poll-pdf.service.ts (98%) rename client/src/app/site/assignments/{ => modules/assignment-poll}/services/assignment-poll.service.spec.ts (100%) rename client/src/app/site/assignments/{ => modules/assignment-poll}/services/assignment-poll.service.ts (97%) create mode 100644 client/src/app/site/cinema/cinema-routing.module.ts create mode 100644 client/src/app/site/cinema/cinema.config.ts create mode 100644 client/src/app/site/cinema/cinema.module.ts create mode 100644 client/src/app/site/cinema/components/cinema/cinema.component.html create mode 100644 client/src/app/site/cinema/components/cinema/cinema.component.scss create mode 100644 client/src/app/site/cinema/components/cinema/cinema.component.spec.ts create mode 100644 client/src/app/site/cinema/components/cinema/cinema.component.ts create mode 100644 client/src/app/site/cinema/components/poll-collection/poll-collection.component.html create mode 100644 client/src/app/site/cinema/components/poll-collection/poll-collection.component.scss create mode 100644 client/src/app/site/cinema/components/poll-collection/poll-collection.component.spec.ts create mode 100644 client/src/app/site/cinema/components/poll-collection/poll-collection.component.ts create mode 100644 server/openslides/core/migrations/0035_autopilot_permission.py diff --git a/client/src/app/core/core-services/app-load.service.ts b/client/src/app/core/core-services/app-load.service.ts index c926b4006..57a2e2a2c 100644 --- a/client/src/app/core/core-services/app-load.service.ts +++ b/client/src/app/core/core-services/app-load.service.ts @@ -3,6 +3,7 @@ import { Injectable, Injector } from '@angular/core'; import { AgendaAppConfig } from '../../site/agenda/agenda.config'; import { AppConfig, ModelEntry, SearchableModelEntry } from '../definitions/app-config'; import { BaseRepository } from 'app/core/repositories/base-repository'; +import { CinemaAppConfig } from 'app/site/cinema/cinema.config'; import { HistoryAppConfig } from 'app/site/history/history.config'; import { ProjectorAppConfig } from 'app/site/projector/projector.config'; import { TopicsAppConfig } from 'app/site/topics/topics.config'; @@ -35,7 +36,8 @@ const appConfigs: AppConfig[] = [ UsersAppConfig, HistoryAppConfig, ProjectorAppConfig, - TopicsAppConfig + TopicsAppConfig, + CinemaAppConfig ]; /** diff --git a/client/src/app/core/core-services/operator.service.ts b/client/src/app/core/core-services/operator.service.ts index 818e7e642..3e9b1f804 100644 --- a/client/src/app/core/core-services/operator.service.ts +++ b/client/src/app/core/core-services/operator.service.ts @@ -40,6 +40,7 @@ export enum Permission { coreCanSeeProjector = 'core.can_see_projector', coreCanManageTags = 'core.can_manage_tags', coreCanSeeLiveStream = 'core.can_see_livestream', + coreCanSeeAutopilot = 'core.can_see_autopilot', mediafilesCanManage = 'mediafiles.can_manage', mediafilesCanSee = 'mediafiles.can_see', motionsCanCreate = 'motions.can_create', diff --git a/client/src/app/core/core-services/projector.service.ts b/client/src/app/core/core-services/projector.service.ts index f7e5e8397..df5c34604 100644 --- a/client/src/app/core/core-services/projector.service.ts +++ b/client/src/app/core/core-services/projector.service.ts @@ -317,7 +317,7 @@ export class ProjectorService { * @param element The projector element * @returns the view model from the projector element */ - public getViewModelFromProjectorElement( + public getViewModelFromIdentifiableProjectorElement( element: IdentifiableProjectorElement ): T { this.assertElementIsMappable(element); @@ -328,12 +328,16 @@ export class ProjectorService { return viewModel; } + public getViewModelFromProjectorElement(element: ProjectorElement): T { + const idElement = this.slideManager.getIdentifiableProjectorElement(element); + return this.getViewModelFromIdentifiableProjectorElement(idElement); + } + /** */ public getSlideTitle(element: ProjectorElement): ProjectorTitle { if (this.slideManager.canSlideBeMappedToModel(element.name)) { - const idElement = this.slideManager.getIdentifiableProjectorElement(element); - const viewModel = this.getViewModelFromProjectorElement(idElement); + const viewModel = this.getViewModelFromProjectorElement(element); if (viewModel) { return viewModel.getProjectorTitle(); } diff --git a/client/src/app/core/repositories/projector/projector-repository.service.ts b/client/src/app/core/repositories/projector/projector-repository.service.ts index bbecea1b4..78ca22075 100644 --- a/client/src/app/core/repositories/projector/projector-repository.service.ts +++ b/client/src/app/core/repositories/projector/projector-repository.service.ts @@ -138,6 +138,14 @@ export class ProjectorRepositoryService extends BaseRepository(`/rest/core/projector/${projector_id}/set_reference_projector/`); } + public getReferenceProjectorObservable(): Observable { + return this.getViewModelListObservable().pipe( + map(projectors => { + return projectors.find(projector => projector.isReferenceProjector); + }) + ); + } + /** * return the id of the current reference projector * prefer the observable whenever possible @@ -146,15 +154,4 @@ export class ProjectorRepositoryService extends BaseRepository projector.isReferenceProjector).id; } - - public getReferenceProjectorIdObservable(): Observable { - return this.getViewModelListObservable().pipe( - map(projectors => { - const refProjector = projectors.find(projector => projector.isReferenceProjector); - if (refProjector) { - return refProjector.id; - } - }) - ); - } } diff --git a/client/src/app/shared/components/assignment-poll-detail-content/assignment-poll-detail-content.component.ts b/client/src/app/shared/components/assignment-poll-detail-content/assignment-poll-detail-content.component.ts index d4ccb5677..03fe1e3c5 100644 --- a/client/src/app/shared/components/assignment-poll-detail-content/assignment-poll-detail-content.component.ts +++ b/client/src/app/shared/components/assignment-poll-detail-content/assignment-poll-detail-content.component.ts @@ -2,7 +2,7 @@ import { Component, Input } from '@angular/core'; import { AssignmentPollMethod } from 'app/shared/models/assignments/assignment-poll'; import { ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll'; -import { AssignmentPollService } from 'app/site/assignments/services/assignment-poll.service'; +import { AssignmentPollService } from 'app/site/assignments/modules/assignment-poll/services/assignment-poll.service'; import { PollData, PollTableData, VotingResult } from 'app/site/polls/services/poll.service'; @Component({ diff --git a/client/src/app/shared/components/list-of-speakers-content/list-of-speakers-content.component.html b/client/src/app/shared/components/list-of-speakers-content/list-of-speakers-content.component.html new file mode 100644 index 000000000..f7cf7ed53 --- /dev/null +++ b/client/src/app/shared/components/list-of-speakers-content/list-of-speakers-content.component.html @@ -0,0 +1,171 @@ + + +

+ + {{ title }} + + + lock + +

+ + + + + + + + {{ 'Last speakers' | translate }} + + + +
+
{{ number + 1 }}.
+
{{ speaker.getTitle() }}
+
+ {{ durationString(speaker) }} ({{ 'Start time' | translate }}: {{ startTimeToString(speaker) }}) +
+
+ +
+
+
+
+
+ + + +
+ + +
+ + mic + + + {{ activeSpeaker.getListTitle() }} + + + + + +
+ + +
+ + + + + + + {{ hasSpokenCount(speaker) + 1 }}. {{ 'contribution' | translate }} + + + + + {{ 'First speech' | translate }} + + + + ({{ speaker.gender | translate }}) + + + + + + + + + + + + + + + + + + star + + + + +
+ + +
+
+ + + + add + {{ 'Create user' | translate }} + + + +
+
+ + +
+
+ + +
+
+
diff --git a/client/src/app/shared/components/list-of-speakers-content/list-of-speakers-content.component.scss b/client/src/app/shared/components/list-of-speakers-content/list-of-speakers-content.component.scss new file mode 100644 index 000000000..bab4353a5 --- /dev/null +++ b/client/src/app/shared/components/list-of-speakers-content/list-of-speakers-content.component.scss @@ -0,0 +1,125 @@ +@import '~assets/styles/variables.scss'; + +.los-title { + margin-left: 25px; +} + +.finished-list { + box-shadow: none !important; + margin-bottom: 15px; + + .mat-list-item { + height: auto; + margin-bottom: 10px; + } + + .finished-speaker-grid { + display: grid; + width: 100%; + grid-gap: 5px; + + grid-template-areas: + 'number name controls' + 'number time controls'; + grid-template-columns: 30px 1fr min-content; + } + + @include desktop { + .finished-speaker-grid { + grid-template-areas: 'number name time controls'; + grid-template-columns: 30px 1fr min-content min-content; + } + } + + .number { + grid-area: number; + margin: 0; + } + + .name { + grid-area: name; + margin: 0; + } + + .time { + grid-area: time; + margin: 0; + //allows pushing this grid area as small as possible and aligns the end to the same level + white-space: nowrap; + font-size: 80%; + } + + .controls { + grid-area: controls; + margin: 0; + opacity: 0.7; + + .mat-icon-button { + height: 20px; + line-height: 1; + + .mat-icon { + line-height: 19px; + } + } + } +} + +.current-speaker { + display: table; + width: -webkit-fill-available; + height: 50px; + margin: 50px 25px 20px 25px; + box-shadow: 0px 3px 10px 0px rgba(0, 0, 0, 0.25); + + .prefix { + display: table-cell; + padding: 0 15px; + vertical-align: middle; + .mat-icon { + vertical-align: middle; + } + } + + .name { + display: table-cell; + vertical-align: middle; + font-weight: bold; + padding-left: 10px; + width: 100%; + } + + .suffix { + display: table-cell; + vertical-align: middle; + white-space: nowrap; + padding-right: 10px; + } +} + +.waiting-list { + padding: 10px 25px 0 25px; +} + +.search-new-speaker-form { + padding: 15px 25px 10px 25px; + width: auto; + + .search-users-field { + width: 100%; + .search-users { + display: grid; + .mat-form-field { + width: 100%; + } + } + } +} + +.add-self-buttons { + padding: 15px 0 20px 25px; +} + +.speaker-warning { + margin-right: 5px; +} diff --git a/client/src/app/shared/components/list-of-speakers-content/list-of-speakers-content.component.spec.ts b/client/src/app/shared/components/list-of-speakers-content/list-of-speakers-content.component.spec.ts new file mode 100644 index 000000000..e6ee1c1a6 --- /dev/null +++ b/client/src/app/shared/components/list-of-speakers-content/list-of-speakers-content.component.spec.ts @@ -0,0 +1,26 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { ListOfSpeakersContentComponent } from './list-of-speakers-content.component'; + +describe('ListOfSpeakersContentComponent', () => { + let component: ListOfSpeakersContentComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ListOfSpeakersContentComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/shared/components/list-of-speakers-content/list-of-speakers-content.component.ts b/client/src/app/shared/components/list-of-speakers-content/list-of-speakers-content.component.ts new file mode 100644 index 000000000..b39c0a43b --- /dev/null +++ b/client/src/app/shared/components/list-of-speakers-content/list-of-speakers-content.component.ts @@ -0,0 +1,344 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + EventEmitter, + Input, + OnInit, + Output, + ViewChild +} from '@angular/core'; +import { FormControl, FormGroup } from '@angular/forms'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Title } from '@angular/platform-browser'; + +import { TranslateService } from '@ngx-translate/core'; +import { BehaviorSubject } from 'rxjs'; + +import { OperatorService } from 'app/core/core-services/operator.service'; +import { ListOfSpeakersRepositoryService } from 'app/core/repositories/agenda/list-of-speakers-repository.service'; +import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service'; +import { ConfigService } from 'app/core/ui-services/config.service'; +import { DurationService } from 'app/core/ui-services/duration.service'; +import { PromptService } from 'app/core/ui-services/prompt.service'; +import { ViewportService } from 'app/core/ui-services/viewport.service'; +import { ViewListOfSpeakers } from 'app/site/agenda/models/view-list-of-speakers'; +import { SpeakerState, ViewSpeaker } from 'app/site/agenda/models/view-speaker'; +import { BaseViewComponent } from 'app/site/base/base-view'; +import { ViewUser } from 'app/site/users/models/view-user'; +import { Selectable } from '../selectable'; +import { SortingListComponent } from '../sorting-list/sorting-list.component'; + +@Component({ + selector: 'os-list-of-speakers-content', + templateUrl: './list-of-speakers-content.component.html', + styleUrls: ['./list-of-speakers-content.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ListOfSpeakersContentComponent extends BaseViewComponent implements OnInit { + @ViewChild(SortingListComponent) + public listElement: SortingListComponent; + + private viewListOfSpeakers: ViewListOfSpeakers; + public finishedSpeakers: ViewSpeaker[]; + public waitingSpeakers: ViewSpeaker[]; + public activeSpeaker: ViewSpeaker; + + /** + * Required for the user search selector + */ + public addSpeakerForm: FormGroup; + + public users = new BehaviorSubject([]); + public filteredUsers = new BehaviorSubject([]); + + public isSortMode: boolean; + + public isMobile: boolean; + + public showFistContributionHint: boolean; + + public get title(): string { + return this.viewListOfSpeakers?.getTitle(); + } + + public get closed(): boolean { + return this.viewListOfSpeakers?.closed; + } + + public get opCanManage(): boolean { + return this.operator.hasPerms(this.permission.agendaCanManageListOfSpeakers); + } + + public get isOpInList(): boolean { + return this.waitingSpeakers.some(speaker => speaker.user_id === this.operator.user.id); + } + + public get canAddSelf(): boolean { + return !this.config.instant('agenda_present_speakers_only') || this.operator.user.is_present; + } + + @Input() + public set speakers(los: ViewListOfSpeakers) { + this.setListOfSpeakers(los); + } + + @Input() + public customTitle: boolean; + + @Input() + public set sortMode(isActive: boolean) { + if (this.isSortMode) { + this.listElement.restore(); + } + this.isSortMode = isActive; + } + + @Output() + private isListOfSpeakersEmptyEvent = new EventEmitter(); + + @Output() + private hasFinishesSpeakersEvent = new EventEmitter(); + + public constructor( + title: Title, + protected translate: TranslateService, + snackBar: MatSnackBar, + private listOfSpeakersRepo: ListOfSpeakersRepositoryService, + private operator: OperatorService, + private promptService: PromptService, + private durationService: DurationService, + private userRepository: UserRepositoryService, + private config: ConfigService, + private viewport: ViewportService, + private cd: ChangeDetectorRef + ) { + super(title, translate, snackBar); + this.addSpeakerForm = new FormGroup({ user_id: new FormControl() }); + } + + public ngOnInit(): void { + this.subscriptions.push( + // Observe the user list + this.userRepository.getViewModelListObservable().subscribe(users => { + this.users.next(users); + this.filterUsers(); + this.cd.markForCheck(); + }), + // ovserve changes to the add-speaker form + this.addSpeakerForm.valueChanges.subscribe(formResult => { + // resetting a form triggers a form.next(null) - check if user_id + if (formResult && formResult.user_id) { + this.addNewSpeaker(formResult.user_id); + } + }), + // observe changes to the viewport + this.viewport.isMobileSubject.subscribe(isMobile => { + this.isMobile = isMobile; + this.cd.markForCheck(); + }), + // observe changes the agenda_present_speakers_only config + this.config.get('agenda_present_speakers_only').subscribe(() => { + this.filterUsers(); + }), + // observe changes to the agenda_show_first_contribution config + this.config.get('agenda_show_first_contribution').subscribe(show => { + this.showFistContributionHint = show; + }) + ); + } + + private isListOfSpeakersEmpty(): void { + if (this.waitingSpeakers && this.waitingSpeakers.length) { + this.isListOfSpeakersEmptyEvent.emit(false); + } else if (this.finishedSpeakers && this.finishedSpeakers.length) { + this.isListOfSpeakersEmptyEvent.emit(false); + } + return this.isListOfSpeakersEmptyEvent.emit(!this.activeSpeaker); + } + + private hasFinishesSpeakers(): void { + this.hasFinishesSpeakersEvent.emit(this.finishedSpeakers?.length > 0); + } + + /** + * Create a speaker out of an id + * + * @param userId the user id to add to the list. No parameter adds the operators user as speaker. + */ + public addNewSpeaker(userId?: number): void { + this.listOfSpeakersRepo + .createSpeaker(this.viewListOfSpeakers, userId) + .then(() => this.addSpeakerForm.reset(), this.raiseError); + } + + /** + * Click on the X button - removes the speaker from the list of speakers + * + * @param speaker optional speaker to remove. If none is given, + * the operator themself is removed + */ + public async onDeleteButton(speaker?: ViewSpeaker): Promise { + const title = this.translate.instant( + 'Are you sure you want to delete this speaker from this list of speakers?' + ); + if (await this.promptService.open(title)) { + try { + await this.listOfSpeakersRepo.delete(this.viewListOfSpeakers, speaker ? speaker.id : null); + this.filterUsers(); + } catch (e) { + this.raiseError(e); + } + } + } + + /** + * Click on the mic button to mark a speaker as speaking + * + * @param speaker the speaker marked in the list + */ + public async onStartButton(speaker: ViewSpeaker): Promise { + try { + await this.listOfSpeakersRepo.startSpeaker(this.viewListOfSpeakers, speaker); + this.filterUsers(); + } catch (e) { + this.raiseError(e); + } + } + + /** + * Click on the mic-cross button to stop the current speaker + */ + public async onStopButton(): Promise { + try { + await this.listOfSpeakersRepo.stopCurrentSpeaker(this.viewListOfSpeakers); + this.filterUsers(); + } catch (e) { + this.raiseError(e); + } + } + + /** + * Click on the star button. Toggles the marked attribute. + * + * @param speaker The speaker clicked on. + */ + public onMarkButton(speaker: ViewSpeaker): void { + this.listOfSpeakersRepo.markSpeaker(this.viewListOfSpeakers, speaker, !speaker.marked).catch(this.raiseError); + } + + /** + * Receives an updated list from sorting-event. + * + * @param sortedSpeakerList The updated list. + */ + public onSortingChanged(sortedSpeakerList: Selectable[]): void { + if (!this.isMobile) { + this.onSaveSorting(sortedSpeakerList); + } + } + + /** + * send the current order of the sorting list to the server + * + * @param sortedSpeakerList The list to save. + */ + public async onSaveSorting(sortedSpeakerList: Selectable[] = this.listElement.sortedItems): Promise { + return await this.listOfSpeakersRepo + .sortSpeakers( + this.viewListOfSpeakers, + sortedSpeakerList.map(el => el.id) + ) + .catch(this.raiseError); + } + + private setListOfSpeakers(viewListOfSpeakers: ViewListOfSpeakers | undefined): void { + this.viewListOfSpeakers = viewListOfSpeakers; + const allSpeakers = viewListOfSpeakers?.speakers.sort((a, b) => a.weight - b.weight); + this.waitingSpeakers = allSpeakers?.filter(speaker => speaker.state === SpeakerState.WAITING); + this.waitingSpeakers?.sort((a: ViewSpeaker, b: ViewSpeaker) => a.weight - b.weight); + this.filterUsers(); + this.finishedSpeakers = allSpeakers?.filter(speaker => speaker.state === SpeakerState.FINISHED); + + // convert begin time to date and sort + this.finishedSpeakers?.sort((a: ViewSpeaker, b: ViewSpeaker) => { + const aTime = new Date(a.begin_time).getTime(); + const bTime = new Date(b.begin_time).getTime(); + return aTime - bTime; + }); + + this.activeSpeaker = allSpeakers?.find(speaker => speaker.state === SpeakerState.CURRENT); + this.hasFinishesSpeakers(); + this.isListOfSpeakersEmpty(); + } + + /** + * Triggers an update of the filter for the list of available potential speakers + * (triggered on an update of users or config) + */ + private filterUsers(): void { + const presentUsersOnly = this.config.instant('agenda_present_speakers_only'); + const users = presentUsersOnly ? this.users.getValue().filter(u => u.is_present) : this.users.getValue(); + if (!this.waitingSpeakers || !this.waitingSpeakers.length) { + this.filteredUsers.next(users); + } else { + this.filteredUsers.next(users.filter(u => !this.waitingSpeakers.some(speaker => speaker.user_id === u.id))); + } + } + + /** + * Imports a new user by the given username. + * + * @param username The name of the new user. + */ + public async onCreateUser(username: string): Promise { + const newUser = await this.userRepository.createFromString(username); + this.addNewSpeaker(newUser.id); + } + + /** + * Checks how often a speaker has already finished speaking + * + * @param speaker + * @returns 0 or the number of times a speaker occurs in finishedSpeakers + */ + public hasSpokenCount(speaker: ViewSpeaker): number { + return this.finishedSpeakers.filter(finishedSpeaker => { + if (finishedSpeaker && finishedSpeaker.user) { + return finishedSpeaker.user.id === speaker.user.id; + } + }).length; + } + + /** + * Returns true if the speaker did never appear on any list of speakers + * + * @param speaker + */ + public isFirstContribution(speaker: ViewSpeaker): boolean { + return this.listOfSpeakersRepo.isFirstContribution(speaker); + } + + /** + * get the duration of a speech + * + * @param speaker + * @returns string representation of the duration in `[MM]M:SS minutes` format + */ + public durationString(speaker: ViewSpeaker): string { + const duration = Math.floor( + (new Date(speaker.end_time).valueOf() - new Date(speaker.begin_time).valueOf()) / 1000 + ); + return `${this.durationService.durationToString(duration, 'm')}`; + } + + /** + * returns a locale-specific version of the starting time for the given speaker item + * + * @param speaker + * @returns a time string using the current language setting of the client + */ + public startTimeToString(speaker: ViewSpeaker): string { + return new Date(speaker.begin_time).toLocaleString(this.translate.currentLang); + } +} diff --git a/client/src/app/shared/pipes/poll-percent-base.pipe.spec.ts b/client/src/app/shared/pipes/poll-percent-base.pipe.spec.ts index 880847114..99bee5f8f 100644 --- a/client/src/app/shared/pipes/poll-percent-base.pipe.spec.ts +++ b/client/src/app/shared/pipes/poll-percent-base.pipe.spec.ts @@ -2,7 +2,7 @@ import { inject, TestBed } from '@angular/core/testing'; import { E2EImportsModule } from 'e2e-imports.module'; -import { AssignmentPollService } from 'app/site/assignments/services/assignment-poll.service'; +import { AssignmentPollService } from 'app/site/assignments/modules/assignment-poll/services/assignment-poll.service'; import { MotionPollService } from 'app/site/motions/services/motion-poll.service'; import { PollPercentBasePipe } from './poll-percent-base.pipe'; diff --git a/client/src/app/shared/pipes/poll-percent-base.pipe.ts b/client/src/app/shared/pipes/poll-percent-base.pipe.ts index 2c15d8f0f..51c04065d 100644 --- a/client/src/app/shared/pipes/poll-percent-base.pipe.ts +++ b/client/src/app/shared/pipes/poll-percent-base.pipe.ts @@ -1,6 +1,6 @@ import { Pipe, PipeTransform } from '@angular/core'; -import { AssignmentPollService } from 'app/site/assignments/services/assignment-poll.service'; +import { AssignmentPollService } from 'app/site/assignments/modules/assignment-poll/services/assignment-poll.service'; import { MotionPollService } from 'app/site/motions/services/motion-poll.service'; import { PollData } from 'app/site/polls/services/poll.service'; diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts index e87bf7192..c4682b0d8 100644 --- a/client/src/app/shared/shared.module.ts +++ b/client/src/app/shared/shared.module.ts @@ -129,6 +129,7 @@ import { UserMenuComponent } from './components/user-menu/user-menu.component'; import { JitsiComponent } from './components/jitsi/jitsi.component'; import { VjsPlayerComponent } from './components/vjs-player/vjs-player.component'; import { LiveStreamComponent } from './components/live-stream/live-stream.component'; +import { ListOfSpeakersContentComponent } from './components/list-of-speakers-content/list-of-speakers-content.component'; /** * Share Module for all "dumb" components and pipes. @@ -298,7 +299,8 @@ import { LiveStreamComponent } from './components/live-stream/live-stream.compon AssignmentPollDetailContentComponent, JitsiComponent, VjsPlayerComponent, - LiveStreamComponent + LiveStreamComponent, + ListOfSpeakersContentComponent ], declarations: [ PermsDirective, @@ -361,7 +363,8 @@ import { LiveStreamComponent } from './components/live-stream/live-stream.compon AssignmentPollDetailContentComponent, JitsiComponent, VjsPlayerComponent, - LiveStreamComponent + LiveStreamComponent, + ListOfSpeakersContentComponent ], providers: [ { diff --git a/client/src/app/site/agenda/components/list-of-speakers/list-of-speakers.component.html b/client/src/app/site/agenda/components/list-of-speakers/list-of-speakers.component.html index b3b90862f..74fa9376b 100644 --- a/client/src/app/site/agenda/components/list-of-speakers/list-of-speakers.component.html +++ b/client/src/app/site/agenda/components/list-of-speakers/list-of-speakers.component.html @@ -1,8 +1,8 @@ @@ -18,7 +18,7 @@ mat-icon-button matTooltip="{{ 'Re-add last speaker' | translate }}" (click)="readdLastSpeaker()" - [disabled]="!finishedSpeakers || !finishedSpeakers.length" + [disabled]="!hasFinishedSpeakers" > undo @@ -26,167 +26,16 @@ - - -

- {{ viewListOfSpeakers.getTitle() }} - - lock - -

- - - - - {{ 'Last speakers' | translate }} - - - -
-
{{ number + 1 }}.
-
{{ speaker.getTitle() }}
-
- {{ durationString(speaker) }} ({{ 'Start time' | translate }}: {{ startTimeToString(speaker) }}) -
-
- -
-
-
-
-
- - - -
- - -
- - mic - - - {{ activeSpeaker.getListTitle() }} - - - - - -
- - -
- - - - - - - - {{ hasSpokenCount(speaker) + 1 }}. {{ 'contribution' | translate }} - - - - - {{ 'First speech' | translate }} - - - - ({{ speaker.gender | translate }}) - - - - - - - - - - - - - - - -
- - -
-
- - - - add - {{ 'Create user' | translate }} - - - -
-
- - -
-
- - -
-
-
+ - @@ -222,9 +71,9 @@ {{ 'Close list of speakers' | translate }} - + - diff --git a/client/src/app/site/agenda/components/list-of-speakers/list-of-speakers.component.scss b/client/src/app/site/agenda/components/list-of-speakers/list-of-speakers.component.scss index 57b09e399..e69de29bb 100644 --- a/client/src/app/site/agenda/components/list-of-speakers/list-of-speakers.component.scss +++ b/client/src/app/site/agenda/components/list-of-speakers/list-of-speakers.component.scss @@ -1,125 +0,0 @@ -@import '~assets/styles/variables.scss'; - -.los-title { - margin-left: 25px; -} - -.finished-list { - box-shadow: none !important; - margin-bottom: 15px; - - .mat-list-item { - height: auto; - margin-bottom: 10px; - } - - .finished-speaker-grid { - display: grid; - width: 100%; - grid-gap: 5px; - - grid-template-areas: - 'number name controls' - 'number time controls'; - grid-template-columns: 30px 1fr min-content; - } - - @include desktop { - .finished-speaker-grid { - grid-template-areas: 'number name time controls'; - grid-template-columns: 30px 1fr min-content min-content; - } - } - - .number { - grid-area: number; - margin: 0; - } - - .name { - grid-area: name; - margin: 0; - } - - .time { - grid-area: time; - margin: 0; - //allows pushing this grid area as small as possible and aligns the end to the same level - white-space: nowrap; - font-size: 80%; - } - - .controls { - grid-area: controls; - margin: 0; - opacity: 0.7; - - .mat-icon-button { - height: 20px; - line-height: 1; - - .mat-icon { - line-height: 19px; - } - } - } -} - -.current-speaker { - display: table; - width: -webkit-fill-available; - height: 50px; - margin: 50px 25px 20px 25px; - box-shadow: 0px 3px 10px 0px rgba(0, 0, 0, 0.25); - - .prefix { - display: table-cell; - padding: 0 15px; - vertical-align: middle; - .mat-icon { - vertical-align: middle; - } - } - - .name { - display: table-cell; - vertical-align: middle; - font-weight: bold; - padding-left: 10px; - width: 100%; - } - - .suffix { - display: table-cell; - vertical-align: middle; - white-space: nowrap; - padding-right: 10px; - } -} - -.waiting-list { - padding: 10px 25px 0 25px; -} - -form { - padding: 15px 25px 10px 25px; - width: auto; - - .search-users-field { - width: 100%; - .search-users { - display: grid; - .mat-form-field { - width: 100%; - } - } - } -} - -.add-self-buttons { - padding: 15px 0 20px 25px; -} - -.speaker-warning { - margin-right: 5px; -} diff --git a/client/src/app/site/agenda/components/list-of-speakers/list-of-speakers.component.ts b/client/src/app/site/agenda/components/list-of-speakers/list-of-speakers.component.ts index 1cb050d20..92f83fb2a 100644 --- a/client/src/app/site/agenda/components/list-of-speakers/list-of-speakers.component.ts +++ b/client/src/app/site/agenda/components/list-of-speakers/list-of-speakers.component.ts @@ -1,31 +1,21 @@ -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit, ViewChild } from '@angular/core'; -import { FormControl, FormGroup } from '@angular/forms'; +import { Component, OnInit, ViewChild } from '@angular/core'; import { MatSnackBar } from '@angular/material/snack-bar'; import { Title } from '@angular/platform-browser'; import { ActivatedRoute } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; -import { BehaviorSubject, Subscription } from 'rxjs'; import { CollectionStringMapperService } from 'app/core/core-services/collection-string-mapper.service'; -import { OperatorService, Permission } from 'app/core/core-services/operator.service'; import { ListOfSpeakersRepositoryService } from 'app/core/repositories/agenda/list-of-speakers-repository.service'; -import { ProjectorRepositoryService } from 'app/core/repositories/projector/projector-repository.service'; -import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service'; -import { ConfigService } from 'app/core/ui-services/config.service'; -import { DurationService } from 'app/core/ui-services/duration.service'; import { PromptService } from 'app/core/ui-services/prompt.service'; import { ViewportService } from 'app/core/ui-services/viewport.service'; -import { Selectable } from 'app/shared/components/selectable'; -import { SortingListComponent } from 'app/shared/components/sorting-list/sorting-list.component'; +import { ListOfSpeakersContentComponent } from 'app/shared/components/list-of-speakers-content/list-of-speakers-content.component'; import { BaseViewComponent } from 'app/site/base/base-view'; import { ProjectorElementBuildDeskriptor } from 'app/site/base/projectable'; import { ViewProjector } from 'app/site/projector/models/view-projector'; import { CurrentListOfSpeakersSlideService } from 'app/site/projector/services/current-list-of-speakers-slide.service'; import { CurrentListOfSpeakersService } from 'app/site/projector/services/current-list-of-speakers.service'; -import { ViewUser } from 'app/site/users/models/view-user'; import { ViewListOfSpeakers } from '../../models/view-list-of-speakers'; -import { SpeakerState, ViewSpeaker } from '../../models/view-speaker'; /** * The list of speakers for agenda items. @@ -33,73 +23,26 @@ import { SpeakerState, ViewSpeaker } from '../../models/view-speaker'; @Component({ selector: 'os-list-of-speakers', templateUrl: './list-of-speakers.component.html', - styleUrls: ['./list-of-speakers.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush + styleUrls: ['./list-of-speakers.component.scss'] }) export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit { - @ViewChild(SortingListComponent) - public listElement: SortingListComponent; - + @ViewChild('content') + private listOfSpeakersContentComponent: ListOfSpeakersContentComponent; /** * Determine if the user is viewing the current list if speakers */ public isCurrentListOfSpeakers = false; - /** - * Holds whether the list is in sort mode or not - */ - public isSortMode = false; - /** * Holds the view item to the given topic */ public viewListOfSpeakers: ViewListOfSpeakers; - /** - * Holds the speakers - */ - public speakers: ViewSpeaker[]; - /** * Holds a list of projectors. Only in CurrentListOfSpeakers mode */ public projectors: ViewProjector[]; - /** - * Holds the subscription to the current projector (if any) - */ - private projectorSubscription: Subscription; - - /** - * Holds the active speaker - */ - public activeSpeaker: ViewSpeaker; - - /** - * Holds the speakers who were marked done - */ - public finishedSpeakers: ViewSpeaker[]; - - /** - * Hold the users - */ - public users = new BehaviorSubject([]); - - /** - * A filtered list of users, excluding those not available to be added to the list - */ - public filteredUsers = new BehaviorSubject([]); - - /** - * Required for the user search selector - */ - public addSpeakerForm: FormGroup; - - /** - * Check, if list-view is seen on mobile-device. - */ - public isMobile = false; - /** * @returns true if the list of speakers list is currently closed */ @@ -107,35 +50,19 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit return this.viewListOfSpeakers && this.viewListOfSpeakers.closed; } - public get isListOfSpeakersEmpty(): boolean { - if (this.speakers && this.speakers.length) { - return false; - } else if (this.finishedSpeakers && this.finishedSpeakers.length) { - return false; - } - return !this.activeSpeaker; - } + public isMobile: boolean; + + public manualSortMode = false; /** - * @returns true if the current user can be added to the list of speakers + * filled by child component */ - public get canAddSelf(): boolean { - return !this.config.instant('agenda_present_speakers_only') || this.operator.user.is_present; - } + public isListOfSpeakersEmpty: boolean; /** - * Used to detect changes in the projector reference. + * filled by child component */ - private closReferenceProjectorId: number | null; - - private closSubscription: Subscription | null; - - public showFistContributionHint: boolean; - - /** - * List of speakers to save temporarily changes made by sorting-list. - */ - private speakerListAsSelectable: Selectable[] = []; + public hasFinishedSpeakers: boolean; /** * Constructor for speaker list component. Generates the forms. @@ -143,7 +70,6 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit * @param title * @param translate * @param snackBar - * @param projectorRepo * @param route Angulars ActivatedRoute * @param DS the DataStore * @param listOfSpeakersRepo Repository for list of speakers @@ -156,30 +82,17 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit title: Title, protected translate: TranslateService, // protected required for ng-translate-extract snackBar: MatSnackBar, - private projectorRepo: ProjectorRepositoryService, private route: ActivatedRoute, private listOfSpeakersRepo: ListOfSpeakersRepositoryService, - private operator: OperatorService, private promptService: PromptService, private currentListOfSpeakersService: CurrentListOfSpeakersService, - private durationService: DurationService, - private userRepository: UserRepositoryService, private collectionStringMapper: CollectionStringMapperService, private currentListOfSpeakersSlideService: CurrentListOfSpeakersSlideService, - private config: ConfigService, - private viewport: ViewportService, - private cd: ChangeDetectorRef + private viewport: ViewportService ) { super(title, translate, snackBar); - this.addSpeakerForm = new FormGroup({ user_id: new FormControl() }); } - /** - * Init. - * - * Observe users, - * React to form changes - */ public ngOnInit(): void { // Check, if we are on the current list of speakers. this.isCurrentListOfSpeakers = @@ -188,76 +101,17 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit : true; if (this.isCurrentListOfSpeakers) { - this.projectors = this.projectorRepo.getViewModelList(); - this.updateClosProjector(); this.subscriptions.push( - this.projectorRepo.getViewModelListObservable().subscribe(newProjectors => { - this.projectors = newProjectors; - this.updateClosProjector(); + this.currentListOfSpeakersService.currentListOfSpeakersObservable.subscribe(clos => { + this.setListOfSpeakers(clos); }) ); } else { const id = +this.route.snapshot.url[this.route.snapshot.url.length - 1].path; - this.setListOfSpeakersId(id); + this.setListOfSpeakersById(id); } - this.subscriptions.push( - // Observe the user list - this.userRepository.getViewModelListObservable().subscribe(users => { - this.users.next(users); - this.filterUsers(); - this.cd.markForCheck(); - }), - // ovserve changes to the add-speaker form - this.addSpeakerForm.valueChanges.subscribe(formResult => { - // resetting a form triggers a form.next(null) - check if user_id - if (formResult && formResult.user_id) { - this.addNewSpeaker(formResult.user_id); - } - }), - // observe changes to the viewport - this.viewport.isMobileSubject.subscribe(isMobile => this.checkSortMode(isMobile)), - // observe changes the agenda_present_speakers_only config - this.config.get('agenda_present_speakers_only').subscribe(() => { - this.filterUsers(); - }), - // observe changes to the agenda_show_first_contribution config - this.config.get('agenda_show_first_contribution').subscribe(show => { - this.showFistContributionHint = show; - }) - ); - } - - public opCanManage(): boolean { - return this.operator.hasPerms(Permission.agendaCanManageListOfSpeakers); - } - - /** - * Shows the current list of speakers (CLOS) of a given projector. - */ - private updateClosProjector(): void { - if (!this.projectors.length) { - return; - } - const referenceProjector = this.projectors[0].referenceProjector; - if (!referenceProjector || referenceProjector.id === this.closReferenceProjectorId) { - return; - } - this.closReferenceProjectorId = referenceProjector.id; - - if (this.projectorSubscription) { - this.projectorSubscription.unsubscribe(); - this.viewListOfSpeakers = null; - } - - this.projectorSubscription = this.currentListOfSpeakersService - .getListOfSpeakersObservable(referenceProjector) - .subscribe(listOfSpeakers => { - if (listOfSpeakers) { - this.setListOfSpeakers(listOfSpeakers); - } - }); - this.subscriptions.push(this.projectorSubscription); + this.subscriptions.push(this.viewport.isMobileSubject.subscribe(isMobile => (this.isMobile = isMobile))); } /** @@ -272,16 +126,14 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit * * @param id the list of speakers id */ - private setListOfSpeakersId(id: number): void { - if (this.closSubscription) { - this.closSubscription.unsubscribe(); - } - - this.closSubscription = this.listOfSpeakersRepo.getViewModelObservable(id).subscribe(listOfSpeakers => { - if (listOfSpeakers) { - this.setListOfSpeakers(listOfSpeakers); - } - }); + private setListOfSpeakersById(id: number): void { + this.subscriptions.push( + this.listOfSpeakersRepo.getViewModelObservable(id).subscribe(listOfSpeakers => { + if (listOfSpeakers) { + this.setListOfSpeakers(listOfSpeakers); + } + }) + ); } private setListOfSpeakers(listOfSpeakers: ViewListOfSpeakers): void { @@ -290,21 +142,6 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit : listOfSpeakers.getTitle() + ` - ${this.translate.instant('List of speakers')}`; super.setTitle(title); this.viewListOfSpeakers = listOfSpeakers; - const allSpeakers = this.viewListOfSpeakers.speakers.sort((a, b) => a.weight - b.weight); - this.speakers = allSpeakers.filter(speaker => speaker.state === SpeakerState.WAITING); - // Since the speaker repository is not a normal repository, sorting cannot be handled there - this.speakers.sort((a: ViewSpeaker, b: ViewSpeaker) => a.weight - b.weight); - this.filterUsers(); - this.finishedSpeakers = allSpeakers.filter(speaker => speaker.state === SpeakerState.FINISHED); - - // convert begin time to date and sort - this.finishedSpeakers.sort((a: ViewSpeaker, b: ViewSpeaker) => { - const aTime = new Date(a.begin_time).getTime(); - const bTime = new Date(b.begin_time).getTime(); - return aTime - bTime; - }); - - this.activeSpeaker = allSpeakers.find(speaker => speaker.state === SpeakerState.CURRENT); } /** @@ -318,80 +155,16 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit return verboseName; } - /** - * Create a speaker out of an id - * - * @param userId the user id to add to the list. No parameter adds the operators user as speaker. - */ - public addNewSpeaker(userId?: number): void { - this.listOfSpeakersRepo - .createSpeaker(this.viewListOfSpeakers, userId) - .then(() => this.addSpeakerForm.reset(), this.raiseError); + public setManualSortMode(active: boolean): void { + this.manualSortMode = active; } /** * Saves sorting on mobile devices. */ - public onMobileSaveSorting(): void { - this.onSaveSorting(this.speakerListAsSelectable); - this.isSortMode = false; - } - - /** - * Receives an updated list from sorting-event. - * - * @param sortedSpeakerList The updated list. - */ - public onSortingChanged(sortedSpeakerList: Selectable[]): void { - this.speakerListAsSelectable = sortedSpeakerList; - if (!this.isMobile) { - this.onSaveSorting(sortedSpeakerList); - } - } - - /** - * Restore old order on cancel - */ - public onCancelSorting(): void { - if (this.isSortMode) { - this.isSortMode = false; - this.listElement.restore(); - } - } - - /** - * Click on the mic button to mark a speaker as speaking - * - * @param speaker the speaker marked in the list - */ - public async onStartButton(speaker: ViewSpeaker): Promise { - try { - await this.listOfSpeakersRepo.startSpeaker(this.viewListOfSpeakers, speaker); - this.filterUsers(); - } catch (e) { - this.raiseError(e); - } - } - - /** - * Click on the mic-cross button - */ - public async onStopButton(): Promise { - try { - await this.listOfSpeakersRepo.stopCurrentSpeaker(this.viewListOfSpeakers); - this.filterUsers(); - } catch (e) { - this.raiseError(e); - } - } - - /** - * Click on the star button. Toggles the marked attribute. - * - * @param speaker The speaker clicked on. - */ - public onMarkButton(speaker: ViewSpeaker): void { - this.listOfSpeakersRepo.markSpeaker(this.viewListOfSpeakers, speaker, !speaker.marked).catch(this.raiseError); + public async onMobileSaveSorting(): Promise { + await this.listOfSpeakersContentComponent.onSaveSorting(); + this.manualSortMode = false; } /** @@ -401,58 +174,6 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit this.listOfSpeakersRepo.readdLastSpeaker(this.viewListOfSpeakers).catch(this.raiseError); } - /** - * Click on the X button - removes the speaker from the list of speakers - * - * @param speaker optional speaker to remove. If none is given, - * the operator themself is removed - */ - public async onDeleteButton(speaker?: ViewSpeaker): Promise { - const title = this.translate.instant( - 'Are you sure you want to delete this speaker from this list of speakers?' - ); - if (await this.promptService.open(title)) { - try { - await this.listOfSpeakersRepo.delete(this.viewListOfSpeakers, speaker ? speaker.id : null); - this.filterUsers(); - } catch (e) { - this.raiseError(e); - } - } - } - - /** - * Returns true if the operator is in the list of (waiting) speakers - * - * @returns whether or not the current operator is in the list - */ - public isOpInList(): boolean { - return this.speakers.some(speaker => speaker.user_id === this.operator.user.id); - } - - /** - * Checks how often a speaker has already finished speaking - * - * @param speaker - * @returns 0 or the number of times a speaker occurs in finishedSpeakers - */ - public hasSpokenCount(speaker: ViewSpeaker): number { - return this.finishedSpeakers.filter(finishedSpeaker => { - if (finishedSpeaker && finishedSpeaker.user) { - return finishedSpeaker.user.id === speaker.user.id; - } - }).length; - } - - /** - * Returns true if the speaker did never appear on any list of speakers - * - * @param speaker - */ - public isFirstContribution(speaker: ViewSpeaker): boolean { - return this.listOfSpeakersRepo.isFirstContribution(speaker); - } - /** * Closes the current list of speakers */ @@ -483,77 +204,4 @@ export class ListOfSpeakersComponent extends BaseViewComponent implements OnInit this.listOfSpeakersRepo.deleteAllSpeakers(this.viewListOfSpeakers); } } - - /** - * returns a locale-specific version of the starting time for the given speaker item - * - * @param speaker - * @returns a time string using the current language setting of the client - */ - public startTimeToString(speaker: ViewSpeaker): string { - return new Date(speaker.begin_time).toLocaleString(this.translate.currentLang); - } - - /** - * get the duration of a speech - * - * @param speaker - * @returns string representation of the duration in `[MM]M:SS minutes` format - */ - public durationString(speaker: ViewSpeaker): string { - const duration = Math.floor( - (new Date(speaker.end_time).valueOf() - new Date(speaker.begin_time).valueOf()) / 1000 - ); - return `${this.durationService.durationToString(duration, 'm')}`; - } - - /** - * Imports a new user by the given username. - * - * @param username The name of the new user. - */ - public async onCreateUser(username: string): Promise { - const newUser = await this.userRepository.createFromString(username); - this.addNewSpeaker(newUser.id); - } - - /** - * Triggers an update of the filter for the list of available potential speakers - * (triggered on an update of users or config) - */ - private filterUsers(): void { - const presentUsersOnly = this.config.instant('agenda_present_speakers_only'); - const users = presentUsersOnly ? this.users.getValue().filter(u => u.is_present) : this.users.getValue(); - if (!this.speakers || !this.speakers.length) { - this.filteredUsers.next(users); - } else { - this.filteredUsers.next(users.filter(u => !this.speakers.some(speaker => speaker.user_id === u.id))); - } - } - - /** - * send the current order of the sorting list to the server - * - * @param sortedSpeakerList The list to save. - */ - private onSaveSorting(sortedSpeakerList: Selectable[]): void { - if (this.isSortMode) { - this.listOfSpeakersRepo - .sortSpeakers( - this.viewListOfSpeakers, - sortedSpeakerList.map(el => el.id) - ) - .catch(this.raiseError); - } - } - - /** - * Check, that the sorting mode is immediately active, if not in mobile-view. - * - * @param isMobile If currently a mobile device is used. - */ - private checkSortMode(isMobile: boolean): void { - this.isMobile = isMobile; - this.isSortMode = !isMobile; - } } diff --git a/client/src/app/site/assignments/assignments-routing.module.ts b/client/src/app/site/assignments/assignments-routing.module.ts index df9dcfbcd..b6f54c68d 100644 --- a/client/src/app/site/assignments/assignments-routing.module.ts +++ b/client/src/app/site/assignments/assignments-routing.module.ts @@ -4,13 +4,15 @@ import { RouterModule, Routes } from '@angular/router'; import { Permission } from 'app/core/core-services/operator.service'; import { AssignmentDetailComponent } from './components/assignment-detail/assignment-detail.component'; import { AssignmentListComponent } from './components/assignment-list/assignment-list.component'; -import { AssignmentPollDetailComponent } from './components/assignment-poll-detail/assignment-poll-detail.component'; const routes: Routes = [ { path: '', component: AssignmentListComponent, pathMatch: 'full' }, { path: 'new', component: AssignmentDetailComponent, data: { basePerm: Permission.assignmentsCanManage } }, { path: ':id', component: AssignmentDetailComponent, data: { basePerm: Permission.assignmentsCanSee } }, - { path: 'polls', children: [{ path: ':id', component: AssignmentPollDetailComponent }] } + { + path: 'polls', + loadChildren: () => import('./modules/assignment-poll/assignment-poll.module').then(m => m.AssignmentPollModule) + } ]; @NgModule({ diff --git a/client/src/app/site/assignments/assignments.module.ts b/client/src/app/site/assignments/assignments.module.ts index 97790913c..effa15add 100644 --- a/client/src/app/site/assignments/assignments.module.ts +++ b/client/src/app/site/assignments/assignments.module.ts @@ -3,23 +3,12 @@ import { NgModule } from '@angular/core'; import { AssignmentDetailComponent } from './components/assignment-detail/assignment-detail.component'; import { AssignmentListComponent } from './components/assignment-list/assignment-list.component'; -import { AssignmentPollDetailComponent } from './components/assignment-poll-detail/assignment-poll-detail.component'; -import { AssignmentPollDialogComponent } from './components/assignment-poll-dialog/assignment-poll-dialog.component'; -import { AssignmentPollVoteComponent } from './components/assignment-poll-vote/assignment-poll-vote.component'; -import { AssignmentPollComponent } from './components/assignment-poll/assignment-poll.component'; +import { AssignmentPollModule } from './modules/assignment-poll/assignment-poll.module'; import { AssignmentsRoutingModule } from './assignments-routing.module'; -import { PollsModule } from '../polls/polls.module'; import { SharedModule } from '../../shared/shared.module'; @NgModule({ - imports: [CommonModule, AssignmentsRoutingModule, SharedModule, PollsModule], - declarations: [ - AssignmentDetailComponent, - AssignmentListComponent, - AssignmentPollComponent, - AssignmentPollDetailComponent, - AssignmentPollVoteComponent, - AssignmentPollDialogComponent - ] + imports: [CommonModule, AssignmentsRoutingModule, AssignmentPollModule, SharedModule], + declarations: [AssignmentDetailComponent, AssignmentListComponent] }) export class AssignmentsModule {} diff --git a/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.spec.ts b/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.spec.ts index 1c51f8bb8..b83dce19d 100644 --- a/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.spec.ts +++ b/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.spec.ts @@ -4,8 +4,8 @@ import { E2EImportsModule } from 'e2e-imports.module'; import { PollProgressComponent } from 'app/site/polls/components/poll-progress/poll-progress.component'; import { AssignmentDetailComponent } from './assignment-detail.component'; -import { AssignmentPollVoteComponent } from '../assignment-poll-vote/assignment-poll-vote.component'; -import { AssignmentPollComponent } from '../assignment-poll/assignment-poll.component'; +import { AssignmentPollVoteComponent } from '../../modules/assignment-poll/components/assignment-poll-vote/assignment-poll-vote.component'; +import { AssignmentPollComponent } from '../../modules/assignment-poll/components/assignment-poll/assignment-poll.component'; describe('AssignmentDetailComponent', () => { let component: AssignmentDetailComponent; diff --git a/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.ts b/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.ts index 736ad9944..4c3a88e4a 100644 --- a/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.ts +++ b/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.ts @@ -22,8 +22,8 @@ import { LocalPermissionsService } from 'app/site/motions/services/local-permiss import { ViewTag } from 'app/site/tags/models/view-tag'; import { ViewUser } from 'app/site/users/models/view-user'; import { AssignmentPdfExportService } from '../../services/assignment-pdf-export.service'; -import { AssignmentPollDialogService } from '../../services/assignment-poll-dialog.service'; -import { AssignmentPollService } from '../../services/assignment-poll.service'; +import { AssignmentPollDialogService } from '../../modules/assignment-poll/services/assignment-poll-dialog.service'; +import { AssignmentPollService } from '../../modules/assignment-poll/services/assignment-poll.service'; import { AssignmentPhases, ViewAssignment } from '../../models/view-assignment'; import { ViewAssignmentPoll } from '../../models/view-assignment-poll'; import { ViewAssignmentRelatedUser } from '../../models/view-assignment-related-user'; diff --git a/client/src/app/site/assignments/modules/assignment-poll/assignment-poll-routing.module.ts b/client/src/app/site/assignments/modules/assignment-poll/assignment-poll-routing.module.ts new file mode 100644 index 000000000..465cb1b38 --- /dev/null +++ b/client/src/app/site/assignments/modules/assignment-poll/assignment-poll-routing.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +import { AssignmentPollDetailComponent } from './components/assignment-poll-detail/assignment-poll-detail.component'; + +const routes: Routes = [{ path: ':id', component: AssignmentPollDetailComponent }]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class AssignmentPollRoutingModule {} diff --git a/client/src/app/site/assignments/modules/assignment-poll/assignment-poll.module.ts b/client/src/app/site/assignments/modules/assignment-poll/assignment-poll.module.ts new file mode 100644 index 000000000..f7cdda681 --- /dev/null +++ b/client/src/app/site/assignments/modules/assignment-poll/assignment-poll.module.ts @@ -0,0 +1,22 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; + +import { SharedModule } from 'app/shared/shared.module'; +import { PollsModule } from 'app/site/polls/polls.module'; +import { AssignmentPollDetailComponent } from './components/assignment-poll-detail/assignment-poll-detail.component'; +import { AssignmentPollDialogComponent } from './components/assignment-poll-dialog/assignment-poll-dialog.component'; +import { AssignmentPollRoutingModule } from './assignment-poll-routing.module'; +import { AssignmentPollVoteComponent } from './components/assignment-poll-vote/assignment-poll-vote.component'; +import { AssignmentPollComponent } from './components/assignment-poll/assignment-poll.component'; + +@NgModule({ + declarations: [ + AssignmentPollComponent, + AssignmentPollDetailComponent, + AssignmentPollVoteComponent, + AssignmentPollDialogComponent + ], + exports: [AssignmentPollComponent, AssignmentPollVoteComponent], + imports: [CommonModule, AssignmentPollRoutingModule, SharedModule, PollsModule] +}) +export class AssignmentPollModule {} diff --git a/client/src/app/site/assignments/components/assignment-poll-detail/assignment-poll-detail-component.scss-theme.scss b/client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll-detail/assignment-poll-detail-component.scss-theme.scss similarity index 100% rename from client/src/app/site/assignments/components/assignment-poll-detail/assignment-poll-detail-component.scss-theme.scss rename to client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll-detail/assignment-poll-detail-component.scss-theme.scss diff --git a/client/src/app/site/assignments/components/assignment-poll-detail/assignment-poll-detail.component.html b/client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll-detail/assignment-poll-detail.component.html similarity index 100% rename from client/src/app/site/assignments/components/assignment-poll-detail/assignment-poll-detail.component.html rename to client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll-detail/assignment-poll-detail.component.html diff --git a/client/src/app/site/assignments/components/assignment-poll-detail/assignment-poll-detail.component.scss b/client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll-detail/assignment-poll-detail.component.scss similarity index 100% rename from client/src/app/site/assignments/components/assignment-poll-detail/assignment-poll-detail.component.scss rename to client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll-detail/assignment-poll-detail.component.scss diff --git a/client/src/app/site/assignments/components/assignment-poll-detail/assignment-poll-detail.component.spec.ts b/client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll-detail/assignment-poll-detail.component.spec.ts similarity index 100% rename from client/src/app/site/assignments/components/assignment-poll-detail/assignment-poll-detail.component.spec.ts rename to client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll-detail/assignment-poll-detail.component.spec.ts diff --git a/client/src/app/site/assignments/components/assignment-poll-detail/assignment-poll-detail.component.ts b/client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll-detail/assignment-poll-detail.component.ts similarity index 98% rename from client/src/app/site/assignments/components/assignment-poll-detail/assignment-poll-detail.component.ts rename to client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll-detail/assignment-poll-detail.component.ts index 12af675e8..ce6b0a5d7 100644 --- a/client/src/app/site/assignments/components/assignment-poll-detail/assignment-poll-detail.component.ts +++ b/client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll-detail/assignment-poll-detail.component.ts @@ -13,10 +13,10 @@ import { GroupRepositoryService } from 'app/core/repositories/users/group-reposi import { ConfigService } from 'app/core/ui-services/config.service'; import { PromptService } from 'app/core/ui-services/prompt.service'; import { VoteValue } from 'app/shared/models/poll/base-vote'; +import { ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll'; import { BasePollDetailComponent } from 'app/site/polls/components/base-poll-detail.component'; import { AssignmentPollDialogService } from '../../services/assignment-poll-dialog.service'; import { AssignmentPollService } from '../../services/assignment-poll.service'; -import { ViewAssignmentPoll } from '../../models/view-assignment-poll'; @Component({ selector: 'os-assignment-poll-detail', diff --git a/client/src/app/site/assignments/components/assignment-poll-dialog/assignment-poll-dialog.component.html b/client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll-dialog/assignment-poll-dialog.component.html similarity index 100% rename from client/src/app/site/assignments/components/assignment-poll-dialog/assignment-poll-dialog.component.html rename to client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll-dialog/assignment-poll-dialog.component.html diff --git a/client/src/app/site/assignments/components/assignment-poll-dialog/assignment-poll-dialog.component.scss b/client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll-dialog/assignment-poll-dialog.component.scss similarity index 100% rename from client/src/app/site/assignments/components/assignment-poll-dialog/assignment-poll-dialog.component.scss rename to client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll-dialog/assignment-poll-dialog.component.scss diff --git a/client/src/app/site/assignments/components/assignment-poll-dialog/assignment-poll-dialog.component.spec.ts b/client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll-dialog/assignment-poll-dialog.component.spec.ts similarity index 100% rename from client/src/app/site/assignments/components/assignment-poll-dialog/assignment-poll-dialog.component.spec.ts rename to client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll-dialog/assignment-poll-dialog.component.spec.ts diff --git a/client/src/app/site/assignments/components/assignment-poll-dialog/assignment-poll-dialog.component.ts b/client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll-dialog/assignment-poll-dialog.component.ts similarity index 98% rename from client/src/app/site/assignments/components/assignment-poll-dialog/assignment-poll-dialog.component.ts rename to client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll-dialog/assignment-poll-dialog.component.ts index d449b8ccd..9089cb555 100644 --- a/client/src/app/site/assignments/components/assignment-poll-dialog/assignment-poll-dialog.component.ts +++ b/client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll-dialog/assignment-poll-dialog.component.ts @@ -12,13 +12,13 @@ import { LOWEST_VOTE_VALUE, PollType } from 'app/shared/models/poll/base-poll'; import { GeneralValueVerbose, VoteValue, VoteValueVerbose } from 'app/shared/models/poll/base-vote'; import { AssignmentPollMethodVerbose, - AssignmentPollPercentBaseVerbose + AssignmentPollPercentBaseVerbose, + ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll'; import { BasePollDialogComponent } from 'app/site/polls/components/base-poll-dialog.component'; import { PollFormComponent } from 'app/site/polls/components/poll-form/poll-form.component'; import { ViewUser } from 'app/site/users/models/view-user'; import { AssignmentPollService } from '../../services/assignment-poll.service'; -import { ViewAssignmentPoll } from '../../models/view-assignment-poll'; type OptionsObject = { user_id: number; user: ViewUser }[]; diff --git a/client/src/app/site/assignments/components/assignment-poll-vote/assignment-poll-vote.component.html b/client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll-vote/assignment-poll-vote.component.html similarity index 100% rename from client/src/app/site/assignments/components/assignment-poll-vote/assignment-poll-vote.component.html rename to client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll-vote/assignment-poll-vote.component.html diff --git a/client/src/app/site/assignments/components/assignment-poll-vote/assignment-poll-vote.component.scss b/client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll-vote/assignment-poll-vote.component.scss similarity index 100% rename from client/src/app/site/assignments/components/assignment-poll-vote/assignment-poll-vote.component.scss rename to client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll-vote/assignment-poll-vote.component.scss diff --git a/client/src/app/site/assignments/components/assignment-poll-vote/assignment-poll-vote.component.spec.ts b/client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll-vote/assignment-poll-vote.component.spec.ts similarity index 100% rename from client/src/app/site/assignments/components/assignment-poll-vote/assignment-poll-vote.component.spec.ts rename to client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll-vote/assignment-poll-vote.component.spec.ts diff --git a/client/src/app/site/assignments/components/assignment-poll-vote/assignment-poll-vote.component.ts b/client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll-vote/assignment-poll-vote.component.ts similarity index 98% rename from client/src/app/site/assignments/components/assignment-poll-vote/assignment-poll-vote.component.ts rename to client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll-vote/assignment-poll-vote.component.ts index dbd06fb54..7aa5b1002 100644 --- a/client/src/app/site/assignments/components/assignment-poll-vote/assignment-poll-vote.component.ts +++ b/client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll-vote/assignment-poll-vote.component.ts @@ -15,8 +15,8 @@ import { VotingService } from 'app/core/ui-services/voting.service'; import { AssignmentPollMethod } from 'app/shared/models/assignments/assignment-poll'; import { PollType } from 'app/shared/models/poll/base-poll'; import { VoteValue } from 'app/shared/models/poll/base-vote'; +import { ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll'; import { BasePollVoteComponent } from 'app/site/polls/components/base-poll-vote.component'; -import { ViewAssignmentPoll } from '../../models/view-assignment-poll'; // TODO: Duplicate interface VoteActions { diff --git a/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.html b/client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll/assignment-poll.component.html similarity index 100% rename from client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.html rename to client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll/assignment-poll.component.html diff --git a/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.scss b/client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll/assignment-poll.component.scss similarity index 100% rename from client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.scss rename to client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll/assignment-poll.component.scss diff --git a/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.spec.ts b/client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll/assignment-poll.component.spec.ts similarity index 100% rename from client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.spec.ts rename to client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll/assignment-poll.component.spec.ts diff --git a/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.ts b/client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll/assignment-poll.component.ts similarity index 97% rename from client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.ts rename to client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll/assignment-poll.component.ts index 76705329f..a537eaffb 100644 --- a/client/src/app/site/assignments/components/assignment-poll/assignment-poll.component.ts +++ b/client/src/app/site/assignments/modules/assignment-poll/components/assignment-poll/assignment-poll.component.ts @@ -10,11 +10,11 @@ import { AssignmentPollRepositoryService } from 'app/core/repositories/assignmen import { PromptService } from 'app/core/ui-services/prompt.service'; import { VotingPrivacyWarningComponent } from 'app/shared/components/voting-privacy-warning/voting-privacy-warning.component'; import { infoDialogSettings } from 'app/shared/utils/dialog-settings'; +import { ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll'; import { BasePollComponent } from 'app/site/polls/components/base-poll.component'; import { AssignmentPollDialogService } from '../../services/assignment-poll-dialog.service'; import { AssignmentPollPdfService } from '../../services/assignment-poll-pdf.service'; import { AssignmentPollService } from '../../services/assignment-poll.service'; -import { ViewAssignmentPoll } from '../../models/view-assignment-poll'; /** * Component for a single assignment poll. Used in assignment detail view diff --git a/client/src/app/site/assignments/services/assignment-poll-dialog.service.spec.ts b/client/src/app/site/assignments/modules/assignment-poll/services/assignment-poll-dialog.service.spec.ts similarity index 100% rename from client/src/app/site/assignments/services/assignment-poll-dialog.service.spec.ts rename to client/src/app/site/assignments/modules/assignment-poll/services/assignment-poll-dialog.service.spec.ts diff --git a/client/src/app/site/assignments/services/assignment-poll-dialog.service.ts b/client/src/app/site/assignments/modules/assignment-poll/services/assignment-poll-dialog.service.ts similarity index 79% rename from client/src/app/site/assignments/services/assignment-poll-dialog.service.ts rename to client/src/app/site/assignments/modules/assignment-poll/services/assignment-poll-dialog.service.ts index d3fae5ccb..53e876e80 100644 --- a/client/src/app/site/assignments/services/assignment-poll-dialog.service.ts +++ b/client/src/app/site/assignments/modules/assignment-poll/services/assignment-poll-dialog.service.ts @@ -3,9 +3,9 @@ import { MatDialog } from '@angular/material/dialog'; import { CollectionStringMapperService } from 'app/core/core-services/collection-string-mapper.service'; import { BasePollDialogService } from 'app/core/ui-services/base-poll-dialog.service'; -import { AssignmentPollDialogComponent } from 'app/site/assignments/components/assignment-poll-dialog/assignment-poll-dialog.component'; +import { AssignmentPollDialogComponent } from '../components/assignment-poll-dialog/assignment-poll-dialog.component'; import { AssignmentPollService } from './assignment-poll.service'; -import { ViewAssignmentPoll } from '../models/view-assignment-poll'; +import { ViewAssignmentPoll } from '../../../models/view-assignment-poll'; /** * Subclassed to provide the right `PollService` and `DialogComponent` diff --git a/client/src/app/site/assignments/services/assignment-poll-pdf.service.spec.ts b/client/src/app/site/assignments/modules/assignment-poll/services/assignment-poll-pdf.service.spec.ts similarity index 100% rename from client/src/app/site/assignments/services/assignment-poll-pdf.service.spec.ts rename to client/src/app/site/assignments/modules/assignment-poll/services/assignment-poll-pdf.service.spec.ts diff --git a/client/src/app/site/assignments/services/assignment-poll-pdf.service.ts b/client/src/app/site/assignments/modules/assignment-poll/services/assignment-poll-pdf.service.ts similarity index 98% rename from client/src/app/site/assignments/services/assignment-poll-pdf.service.ts rename to client/src/app/site/assignments/modules/assignment-poll/services/assignment-poll-pdf.service.ts index 3cb908c06..43ac6181f 100644 --- a/client/src/app/site/assignments/services/assignment-poll-pdf.service.ts +++ b/client/src/app/site/assignments/modules/assignment-poll/services/assignment-poll-pdf.service.ts @@ -8,7 +8,7 @@ import { AssignmentRepositoryService } from 'app/core/repositories/assignments/a import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service'; import { ConfigService } from 'app/core/ui-services/config.service'; import { AssignmentPollMethod } from 'app/shared/models/assignments/assignment-poll'; -import { ViewAssignmentPoll } from '../models/view-assignment-poll'; +import { ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll'; /** * Creates a pdf for a motion poll. Takes as input any motionPoll diff --git a/client/src/app/site/assignments/services/assignment-poll.service.spec.ts b/client/src/app/site/assignments/modules/assignment-poll/services/assignment-poll.service.spec.ts similarity index 100% rename from client/src/app/site/assignments/services/assignment-poll.service.spec.ts rename to client/src/app/site/assignments/modules/assignment-poll/services/assignment-poll.service.spec.ts diff --git a/client/src/app/site/assignments/services/assignment-poll.service.ts b/client/src/app/site/assignments/modules/assignment-poll/services/assignment-poll.service.ts similarity index 97% rename from client/src/app/site/assignments/services/assignment-poll.service.ts rename to client/src/app/site/assignments/modules/assignment-poll/services/assignment-poll.service.ts index ec7dd8d81..eb3f4fbe2 100644 --- a/client/src/app/site/assignments/services/assignment-poll.service.ts +++ b/client/src/app/site/assignments/modules/assignment-poll/services/assignment-poll.service.ts @@ -13,6 +13,8 @@ import { import { MajorityMethod, PollType, VOTE_UNDOCUMENTED } from 'app/shared/models/poll/base-poll'; import { ParsePollNumberPipe } from 'app/shared/pipes/parse-poll-number.pipe'; import { PollKeyVerbosePipe } from 'app/shared/pipes/poll-key-verbose.pipe'; +import { ViewAssignmentOption } from 'app/site/assignments/models/view-assignment-option'; +import { ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll'; import { PollData, PollDataOption, @@ -20,8 +22,6 @@ import { PollTableData, VotingResult } from 'app/site/polls/services/poll.service'; -import { ViewAssignmentOption } from '../models/view-assignment-option'; -import { ViewAssignmentPoll } from '../models/view-assignment-poll'; @Injectable({ providedIn: 'root' diff --git a/client/src/app/site/assignments/services/assignment-pdf.service.ts b/client/src/app/site/assignments/services/assignment-pdf.service.ts index f948164b4..50f144482 100644 --- a/client/src/app/site/assignments/services/assignment-pdf.service.ts +++ b/client/src/app/site/assignments/services/assignment-pdf.service.ts @@ -8,7 +8,7 @@ import { ParsePollNumberPipe } from 'app/shared/pipes/parse-poll-number.pipe'; import { PollKeyVerbosePipe } from 'app/shared/pipes/poll-key-verbose.pipe'; import { PollPercentBasePipe } from 'app/shared/pipes/poll-percent-base.pipe'; import { PollTableData, VotingResult } from 'app/site/polls/services/poll.service'; -import { AssignmentPollService } from './assignment-poll.service'; +import { AssignmentPollService } from '../modules/assignment-poll/services/assignment-poll.service'; import { ViewAssignment } from '../models/view-assignment'; import { ViewAssignmentPoll } from '../models/view-assignment-poll'; diff --git a/client/src/app/site/cinema/cinema-routing.module.ts b/client/src/app/site/cinema/cinema-routing.module.ts new file mode 100644 index 000000000..6c7c7d31e --- /dev/null +++ b/client/src/app/site/cinema/cinema-routing.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +import { CinemaComponent } from './components/cinema/cinema.component'; + +const routes: Routes = [{ path: '', component: CinemaComponent, pathMatch: 'full' }]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class CinemaRoutingModule {} diff --git a/client/src/app/site/cinema/cinema.config.ts b/client/src/app/site/cinema/cinema.config.ts new file mode 100644 index 000000000..dc7085db0 --- /dev/null +++ b/client/src/app/site/cinema/cinema.config.ts @@ -0,0 +1,16 @@ +import { Permission } from 'app/core/core-services/operator.service'; +import { AppConfig } from 'app/core/definitions/app-config'; + +export const CinemaAppConfig: AppConfig = { + name: 'cinema', + models: [], + mainMenuEntries: [ + { + route: '/autopilot', + displayName: 'Autopilot', + icon: 'sync', + weight: 150, + permission: Permission.coreCanSeeAutopilot + } + ] +}; diff --git a/client/src/app/site/cinema/cinema.module.ts b/client/src/app/site/cinema/cinema.module.ts new file mode 100644 index 000000000..9e0b47ea3 --- /dev/null +++ b/client/src/app/site/cinema/cinema.module.ts @@ -0,0 +1,15 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; + +import { SharedModule } from 'app/shared/shared.module'; +import { AssignmentPollModule } from '../assignments/modules/assignment-poll/assignment-poll.module'; +import { CinemaRoutingModule } from './cinema-routing.module'; +import { CinemaComponent } from './components/cinema/cinema.component'; +import { MotionPollModule } from '../motions/modules/motion-poll/motion-poll.module'; +import { PollCollectionComponent } from './components/poll-collection/poll-collection.component'; + +@NgModule({ + imports: [CommonModule, CinemaRoutingModule, MotionPollModule, AssignmentPollModule, SharedModule], + declarations: [CinemaComponent, PollCollectionComponent] +}) +export class CinemaModule {} diff --git a/client/src/app/site/cinema/components/cinema/cinema.component.html b/client/src/app/site/cinema/components/cinema/cinema.component.html new file mode 100644 index 000000000..63948b8bd --- /dev/null +++ b/client/src/app/site/cinema/components/cinema/cinema.component.html @@ -0,0 +1,45 @@ + +
+

{{ 'Autopilot' | translate }}

+
+
+ + + +

+ {{ title }} + + open_in_new + +

+
+ + + +

+ + {{ 'List of speakers' | translate }} + + + lock + +

+
+ + + + + +

{{ projectorTitle | translate }}

+ +
+ +
+
+
diff --git a/client/src/app/site/cinema/components/cinema/cinema.component.scss b/client/src/app/site/cinema/components/cinema/cinema.component.scss new file mode 100644 index 000000000..87a379220 --- /dev/null +++ b/client/src/app/site/cinema/components/cinema/cinema.component.scss @@ -0,0 +1,3 @@ +.projector { + border: 1px solid lightgray; +} diff --git a/client/src/app/site/cinema/components/cinema/cinema.component.spec.ts b/client/src/app/site/cinema/components/cinema/cinema.component.spec.ts new file mode 100644 index 000000000..323578b38 --- /dev/null +++ b/client/src/app/site/cinema/components/cinema/cinema.component.spec.ts @@ -0,0 +1,27 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { CinemaComponent } from './cinema.component'; + +describe('CinemaComponent', () => { + let component: CinemaComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [CinemaComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(CinemaComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/cinema/components/cinema/cinema.component.ts b/client/src/app/site/cinema/components/cinema/cinema.component.ts new file mode 100644 index 000000000..29853a520 --- /dev/null +++ b/client/src/app/site/cinema/components/cinema/cinema.component.ts @@ -0,0 +1,113 @@ +import { Component, OnInit } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Title } from '@angular/platform-browser'; + +import { TranslateService } from '@ngx-translate/core'; + +import { OperatorService } from 'app/core/core-services/operator.service'; +import { ProjectorService } from 'app/core/core-services/projector.service'; +import { ProjectorRepositoryService } from 'app/core/repositories/projector/projector-repository.service'; +import { DetailNavigable, isDetailNavigable } from 'app/shared/models/base/detail-navigable'; +import { ProjectorElement } from 'app/shared/models/core/projector'; +import { ViewListOfSpeakers } from 'app/site/agenda/models/view-list-of-speakers'; +import { BaseProjectableViewModel } from 'app/site/base/base-projectable-view-model'; +import { BaseViewComponent } from 'app/site/base/base-view'; +import { ViewProjector } from 'app/site/projector/models/view-projector'; +import { CurrentListOfSpeakersService } from 'app/site/projector/services/current-list-of-speakers.service'; + +@Component({ + selector: 'os-cinema', + templateUrl: './cinema.component.html', + styleUrls: ['./cinema.component.scss'] +}) +export class CinemaComponent extends BaseViewComponent implements OnInit { + public listOfSpeakers: ViewListOfSpeakers; + public projector: ViewProjector; + private currentProjectorElement: ProjectorElement; + public projectedViewModel: BaseProjectableViewModel; + + public get title(): string { + if (this.projectedViewModel) { + return this.projectedViewModel.getListTitle(); + } else if (this.currentProjectorElement) { + return this.projectorService.getSlideTitle(this.currentProjectorElement)?.title; + } else { + return ''; + } + } + + public get projectorTitle(): string { + return this.projector?.getTitle() || ''; + } + + public get closUrl(): string { + if (this.listOfSpeakers && this.operator.hasPerms(this.permission.agendaCanManageListOfSpeakers)) { + return this.listOfSpeakers?.listOfSpeakersUrl; + } else { + return ''; + } + } + + public get isLosClosed(): boolean { + return this.listOfSpeakers?.closed; + } + + public get viewModelUrl(): string { + if (this.projectedViewModel && isDetailNavigable(this.projectedViewModel)) { + return (this.projectedViewModel as DetailNavigable).getDetailStateURL(); + } else { + return ''; + } + } + + public get projectorUrl(): string { + if (this.projector) { + if (this.operator.hasPerms(this.permission.coreCanManageProjector)) { + return `/projectors/detail/${this.projector.id}`; + } else { + return `/projector/${this.projector.id}`; + } + } else { + return ''; + } + } + + public get projectionTarget(): '_blank' | '_self' { + if (this.operator.hasPerms(this.permission.coreCanManageProjector)) { + return '_self'; + } else { + return '_blank'; + } + } + + public constructor( + title: Title, + translate: TranslateService, + snackBar: MatSnackBar, + private operator: OperatorService, + private projectorService: ProjectorService, + private projectorRepo: ProjectorRepositoryService, + private closService: CurrentListOfSpeakersService + ) { + super(title, translate, snackBar); + } + + public ngOnInit(): void { + this.subscriptions.push( + this.projectorRepo.getReferenceProjectorObservable().subscribe(refProjector => { + this.projector = refProjector; + this.currentProjectorElement = refProjector?.elements[0] || null; + if (this.currentProjectorElement) { + this.projectedViewModel = this.projectorService.getViewModelFromProjectorElement( + this.currentProjectorElement + ); + } else { + this.projectedViewModel = null; + } + }), + this.closService.currentListOfSpeakersObservable.subscribe(clos => { + this.listOfSpeakers = clos; + }) + ); + } +} diff --git a/client/src/app/site/cinema/components/poll-collection/poll-collection.component.html b/client/src/app/site/cinema/components/poll-collection/poll-collection.component.html new file mode 100644 index 000000000..6e6c4d3a7 --- /dev/null +++ b/client/src/app/site/cinema/components/poll-collection/poll-collection.component.html @@ -0,0 +1,13 @@ + +

+ {{ getPollVoteTitle(poll) }} +

+ +
+ +
+ +
+ +
+
diff --git a/client/src/app/site/cinema/components/poll-collection/poll-collection.component.scss b/client/src/app/site/cinema/components/poll-collection/poll-collection.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/client/src/app/site/cinema/components/poll-collection/poll-collection.component.spec.ts b/client/src/app/site/cinema/components/poll-collection/poll-collection.component.spec.ts new file mode 100644 index 000000000..dd9737e05 --- /dev/null +++ b/client/src/app/site/cinema/components/poll-collection/poll-collection.component.spec.ts @@ -0,0 +1,27 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { PollCollectionComponent } from './poll-collection.component'; + +describe('PollCollectionComponent', () => { + let component: PollCollectionComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [PollCollectionComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(PollCollectionComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/cinema/components/poll-collection/poll-collection.component.ts b/client/src/app/site/cinema/components/poll-collection/poll-collection.component.ts new file mode 100644 index 000000000..5adb9c46a --- /dev/null +++ b/client/src/app/site/cinema/components/poll-collection/poll-collection.component.ts @@ -0,0 +1,72 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Title } from '@angular/platform-browser'; + +import { TranslateService } from '@ngx-translate/core'; +import { map } from 'rxjs/operators'; + +import { BaseViewComponent } from 'app/site/base/base-view'; +import { BaseViewModel } from 'app/site/base/base-view-model'; +import { ViewBasePoll } from 'app/site/polls/models/view-base-poll'; +import { PollListObservableService } from 'app/site/polls/services/poll-list-observable.service'; + +@Component({ + selector: 'os-poll-collection', + templateUrl: './poll-collection.component.html', + styleUrls: ['./poll-collection.component.scss'] +}) +export class PollCollectionComponent extends BaseViewComponent implements OnInit { + public polls: ViewBasePoll[]; + + @Input() + private currentProjection: BaseViewModel; + + private get showExtendedTitle(): boolean { + const areAllPollsSameModel = this.polls.every( + poll => this.polls[0].getContentObject() === poll.getContentObject() + ); + + if (this.currentProjection && areAllPollsSameModel) { + return this.polls[0].getContentObject() !== this.currentProjection; + } else { + return !areAllPollsSameModel; + } + } + + public constructor( + title: Title, + translate: TranslateService, + snackBar: MatSnackBar, + private pollService: PollListObservableService + ) { + super(title, translate, snackBar); + } + + public ngOnInit(): void { + this.subscriptions.push( + this.pollService + .getViewModelListObservable() + .pipe(map(polls => polls.filter(poll => poll.canBeVotedFor()))) + .subscribe(polls => { + this.polls = polls; + }) + ); + } + + public getPollVoteTitle(poll: ViewBasePoll): string { + const contentObject = poll.getContentObject(); + const listTitle = contentObject.getListTitle(); + const model = contentObject.getVerboseName(); + const pollTitle = poll.getTitle(); + + if (this.showExtendedTitle) { + return `(${model}) ${listTitle} - ${pollTitle}`; + } else { + return pollTitle; + } + } + + public getPollDetailLink(poll: ViewBasePoll): string { + return poll.parentLink; + } +} diff --git a/client/src/app/site/motions/modules/motion-poll/motion-poll-vote/motion-poll-vote.component.html b/client/src/app/site/motions/modules/motion-poll/motion-poll-vote/motion-poll-vote.component.html index 35559d1cf..2b237a0b6 100644 --- a/client/src/app/site/motions/modules/motion-poll/motion-poll-vote/motion-poll-vote.component.html +++ b/client/src/app/site/motions/modules/motion-poll/motion-poll-vote/motion-poll-vote.component.html @@ -1,6 +1,3 @@ -
- -
diff --git a/client/src/app/site/motions/modules/motion-poll/motion-poll.module.ts b/client/src/app/site/motions/modules/motion-poll/motion-poll.module.ts index 1c781d657..5cc049588 100644 --- a/client/src/app/site/motions/modules/motion-poll/motion-poll.module.ts +++ b/client/src/app/site/motions/modules/motion-poll/motion-poll.module.ts @@ -10,7 +10,7 @@ import { MotionPollComponent } from './motion-poll/motion-poll.component'; @NgModule({ imports: [CommonModule, SharedModule, MotionPollRoutingModule, PollsModule], - exports: [MotionPollComponent], + exports: [MotionPollComponent, MotionPollVoteComponent], declarations: [MotionPollComponent, MotionPollDetailComponent, MotionPollVoteComponent] }) export class MotionPollModule {} diff --git a/client/src/app/site/motions/modules/motion-poll/motion-poll/motion-poll.component.html b/client/src/app/site/motions/modules/motion-poll/motion-poll/motion-poll.component.html index beb3a71fa..25a8fc426 100644 --- a/client/src/app/site/motions/modules/motion-poll/motion-poll/motion-poll.component.html +++ b/client/src/app/site/motions/modules/motion-poll/motion-poll/motion-poll.component.html @@ -57,6 +57,9 @@ +
+ +
diff --git a/client/src/app/site/projector/services/current-list-of-speakers.service.ts b/client/src/app/site/projector/services/current-list-of-speakers.service.ts index 7b7cf11b2..2f883e147 100644 --- a/client/src/app/site/projector/services/current-list-of-speakers.service.ts +++ b/client/src/app/site/projector/services/current-list-of-speakers.service.ts @@ -19,9 +19,9 @@ import { ViewProjector } from '../models/view-projector'; }) export class CurrentListOfSpeakersService { /** - * Id of the current lost of speakers projector. Filled through observer + * Current clos reference projector */ - private closId: number; + private closRefProjector: ViewProjector; /** * This map holds the current (number or null) los-id for the the projector. @@ -56,9 +56,9 @@ export class CurrentListOfSpeakersService { } }); - this.projectorRepo.getReferenceProjectorIdObservable().subscribe(closId => { - if (closId) { - this.closId = closId; + this.projectorRepo.getReferenceProjectorObservable().subscribe(clos => { + if (clos) { + this.closRefProjector = clos; this.currentListOfSpeakerSubject.next(this.getCurrentListOfSpeakers()); } }); @@ -68,8 +68,7 @@ export class CurrentListOfSpeakersService { * Use the subject to get it */ private getCurrentListOfSpeakers(): ViewListOfSpeakers | null { - const refProjector = this.projectorRepo.getViewModel(this.closId); - return this.getCurrentListOfSpeakersForProjector(refProjector); + return this.getCurrentListOfSpeakersForProjector(this.closRefProjector); } /** @@ -135,7 +134,9 @@ export class CurrentListOfSpeakersService { for (const nonStableElement of nonStableElements) { const identifiableNonStableElement = this.slideManager.getIdentifiableProjectorElement(nonStableElement); try { - const viewModel = this.projectorService.getViewModelFromProjectorElement(identifiableNonStableElement); + const viewModel = this.projectorService.getViewModelFromIdentifiableProjectorElement( + identifiableNonStableElement + ); if (isBaseViewModelWithListOfSpeakers(viewModel)) { return viewModel.listOfSpeakers; } diff --git a/client/src/app/site/site-routing.module.ts b/client/src/app/site/site-routing.module.ts index 78592596e..ed26eab4e 100644 --- a/client/src/app/site/site-routing.module.ts +++ b/client/src/app/site/site-routing.module.ts @@ -75,6 +75,11 @@ const routes: Routes = [ loadChildren: () => import('./polls/polls.module').then(m => m.PollsModule), // one of them is sufficient data: { basePerm: [Permission.motionsCanSee, Permission.assignmentsCanSee] } + }, + { + path: 'autopilot', + loadChildren: () => import('./cinema/cinema.module').then(m => m.CinemaModule), + data: { basePerm: Permission.coreCanSeeAutopilot } } ], canActivateChild: [AuthGuard] diff --git a/client/src/app/site/site.component.html b/client/src/app/site/site.component.html index a44da9e74..c18fb08a3 100644 --- a/client/src/app/site/site.component.html +++ b/client/src/app/site/site.component.html @@ -25,7 +25,7 @@ - +