diff --git a/client/src/app/core/services/http.service.ts b/client/src/app/core/services/http.service.ts index 107d9140e..2c9f22e29 100644 --- a/client/src/app/core/services/http.service.ts +++ b/client/src/app/core/services/http.service.ts @@ -28,6 +28,14 @@ export class HttpService { */ public constructor(private http: HttpClient, private translate: TranslateService) {} + /** + * Send the a http request the the given URL. + * Optionally accepts a request body. + * + * @param url the target url, usually starting with /rest + * @param method the required HTTP method (i.e get, post, put) + * @param data optional, if sending a data body is required + */ private async send(url: string, method: HTTPMethod, data?: any): Promise { if (!url.endsWith('/')) { url += '/'; @@ -76,7 +84,7 @@ export class HttpService { } else if (e.status === 500) { error += this.translate.instant('A server error occured. Please contact your system administrator.'); } else if (e.status > 500) { - error += this.translate.instant('The server cound not be reached') + ` (${e.status})` + error += this.translate.instant('The server cound not be reached') + ` (${e.status})`; } else { error += e.message; } diff --git a/client/src/app/shared/components/sorting-list/sorting-list.component.html b/client/src/app/shared/components/sorting-list/sorting-list.component.html index f7522b16e..db84d4b0f 100644 --- a/client/src/app/shared/components/sorting-list/sorting-list.component.html +++ b/client/src/app/shared/components/sorting-list/sorting-list.component.html @@ -1,10 +1,17 @@
-
+
- drag_handle + + unfold_more
- {{item}} + + {{ i+1 }}.  + {{ item }} +
+
+ +
diff --git a/client/src/app/shared/components/sorting-list/sorting-list.component.scss b/client/src/app/shared/components/sorting-list/sorting-list.component.scss index fdb9067d6..8c8126732 100644 --- a/client/src/app/shared/components/sorting-list/sorting-list.component.scss +++ b/client/src/app/shared/components/sorting-list/sorting-list.component.scss @@ -2,23 +2,17 @@ width: 75%; max-width: 100%; border: solid 1px #ccc; - min-height: 60px; display: block; - background: white; + background: white; // TODO theme border-radius: 4px; overflow: hidden; } .box { - padding: 20px 10px; + width: 100%; border-bottom: solid 1px #ccc; color: rgba(0, 0, 0, 0.87); - display: flex; - flex-direction: row; - align-items: left; - justify-content: space-between; - box-sizing: border-box; - background: white; + background: white; // TODO theme font-size: 14px; } @@ -34,7 +28,7 @@ } .cdk-drag-animating { - transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); + transition: transform 125ms ease-in-out; } .box:last-child { @@ -42,23 +36,30 @@ } .line { - display: grid; - grid-template-rows: auto; - grid-template-columns: 15% 85%; - width: 100%; - - > div { - grid-row-start: 1; - grid-row-end: span 1; - grid-column-end: span 2; - } + display: table; + min-height: 60px; .section-one { - grid-column-start: 1; + display: table-cell; + padding: 0 10px; + line-height: 0px; + vertical-align: middle; + width: 50px; + color: slategrey; cursor: move; } .section-two { - grid-column-start: 2; + display: table-cell; + vertical-align: middle; + width: 80%; + } + + .section-three { + display: table-cell; + padding-right: 10px; + vertical-align: middle; + width: auto; + white-space: nowrap; } } diff --git a/client/src/app/shared/components/sorting-list/sorting-list.component.ts b/client/src/app/shared/components/sorting-list/sorting-list.component.ts index a9ac7b8e1..5aacdf068 100644 --- a/client/src/app/shared/components/sorting-list/sorting-list.component.ts +++ b/client/src/app/shared/components/sorting-list/sorting-list.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, Input } from '@angular/core'; +import { Component, OnInit, Input, Output, EventEmitter, ContentChild, TemplateRef } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'; import { Selectable } from '../selectable'; @@ -15,7 +15,10 @@ import { EmptySelectable } from '../empty-selectable'; * * ```html * + * [input]="listOfSelectables" + * [live]="true" + * [count]="true" + * (sortEvent)="onSortingChange($event)"> * * ``` * @@ -27,34 +30,81 @@ import { EmptySelectable } from '../empty-selectable'; }) export class SortingListComponent implements OnInit { /** - * The Input List Values + * Sorted and returned */ - @Input() - public input: Array; - public array: Array; /** - * Empty constructor + * Declare the templateRef to coexist between parent in child */ - public constructor(public translate: TranslateService) {} + @ContentChild(TemplateRef) + public templateRef: TemplateRef; - public ngOnInit(): void { - this.array = []; - if (this.input) { - this.input.forEach(inputElement => { - this.array.push(inputElement); - }); - } else { - this.array.push(new EmptySelectable(this.translate)); + /** + * Set to true if events are directly fired after sorting. + * usually combined with sortEvent. + * Prevents the `@input` from resetting the sorting + * + * @example + * ```html + * + * ``` + */ + @Input() + public live = false; + + /** Determine whether to put an index number in front of the list */ + @Input() + public count = false; + + /** + * The Input List Values + * + * If live updates are enabled, new values are always converted into the sorting array. + * + * If live updates are disabled, new values are processed when the auto update adds + * or removes relevant objects + */ + @Input() + public set input(newValues: Array) { + if (newValues) { + if (this.array.length !== newValues.length || this.live) { + this.array = []; + this.array = newValues.map(val => val); + } else if (this.array.length === 0) { + this.array.push(new EmptySelectable(this.translate)); + } } } + /** + * Inform the parent view about sorting. + * Alternative approach to submit a new order of elements + */ + @Output() + public sortEvent = new EventEmitter>(); + + /** + * Constructor for the sorting list. + * + * Creates an empty array. + * @param translate the translation service + */ + public constructor(public translate: TranslateService) { + this.array = []; + } + + /** + * Required by components using the selector as directive + */ + public ngOnInit(): void {} + /** * drop event * @param event the event */ public drop(event: CdkDragDrop): void { moveItemInArray(this.array, event.previousIndex, event.currentIndex); + this.sortEvent.emit(this.array); } } diff --git a/client/src/app/shared/models/agenda/speaker.ts b/client/src/app/shared/models/agenda/speaker.ts index 0348a3e1d..c321c3421 100644 --- a/client/src/app/shared/models/agenda/speaker.ts +++ b/client/src/app/shared/models/agenda/speaker.ts @@ -1,12 +1,13 @@ -import { Deserializer } from '../base/deserializer'; +import { BaseModel } from '../base/base-model'; /** - * Representation of a speaker in an agenda item + * Representation of a speaker in an agenda item. * + * Needs to be a baseModel since it has an own view class. * Part of the 'speakers' list. * @ignore */ -export class Speaker extends Deserializer { +export class Speaker extends BaseModel { public id: number; public user_id: number; public begin_time: string; // TODO this is a time object @@ -22,4 +23,12 @@ export class Speaker extends Deserializer { public constructor(input?: any) { super(input); } + + /** + * Getting the title of a speaker does not make much sense. + * Usually it would refer to the title of a user. + */ + public getTitle(): string { + return ''; + } } diff --git a/client/src/app/shared/models/topics/topic.ts b/client/src/app/shared/models/topics/topic.ts index cf4255cf8..1c38938fb 100644 --- a/client/src/app/shared/models/topics/topic.ts +++ b/client/src/app/shared/models/topics/topic.ts @@ -25,6 +25,6 @@ export class Topic extends AgendaBaseModel { } public getDetailStateURL(): string { - return 'TODO'; + return `/agenda/topics/${this.id}`; } } diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts index 413ba9da3..30f43c88e 100644 --- a/client/src/app/shared/shared.module.ts +++ b/client/src/app/shared/shared.module.ts @@ -52,6 +52,7 @@ import { SearchValueSelectorComponent } from './components/search-value-selector import { OpenSlidesDateAdapter } from './date-adapter'; import { PromptDialogComponent } from './components/prompt-dialog/prompt-dialog.component'; import { SortingListComponent } from './components/sorting-list/sorting-list.component'; +import { SpeakerListComponent } from 'app/site/agenda/components/speaker-list/speaker-list.component'; /** * Share Module for all "dumb" components and pipes. @@ -151,7 +152,8 @@ import { SortingListComponent } from './components/sorting-list/sorting-list.com PrivacyPolicyContentComponent, SearchValueSelectorComponent, PromptDialogComponent, - SortingListComponent + SortingListComponent, + SpeakerListComponent ], providers: [ { provide: DateAdapter, useClass: OpenSlidesDateAdapter }, diff --git a/client/src/app/site/agenda/agenda-routing.module.ts b/client/src/app/site/agenda/agenda-routing.module.ts index 92d540953..992272b0c 100644 --- a/client/src/app/site/agenda/agenda-routing.module.ts +++ b/client/src/app/site/agenda/agenda-routing.module.ts @@ -1,8 +1,15 @@ import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; -import { AgendaListComponent } from './agenda-list/agenda-list.component'; +import { AgendaListComponent } from './components/agenda-list/agenda-list.component'; +import { TopicDetailComponent } from './components/topic-detail/topic-detail.component'; +import { SpeakerListComponent } from './components/speaker-list/speaker-list.component'; -const routes: Routes = [{ path: '', component: AgendaListComponent }]; +const routes: Routes = [ + { path: '', component: AgendaListComponent }, + { path: 'topics/new', component: TopicDetailComponent }, + { path: 'topics/:id', component: TopicDetailComponent }, + { path: ':id/speakers', component: SpeakerListComponent } +]; @NgModule({ imports: [RouterModule.forChild(routes)], diff --git a/client/src/app/site/agenda/agenda.module.ts b/client/src/app/site/agenda/agenda.module.ts index 723439c93..5db2cc74d 100644 --- a/client/src/app/site/agenda/agenda.module.ts +++ b/client/src/app/site/agenda/agenda.module.ts @@ -3,10 +3,14 @@ import { CommonModule } from '@angular/common'; import { AgendaRoutingModule } from './agenda-routing.module'; import { SharedModule } from '../../shared/shared.module'; -import { AgendaListComponent } from './agenda-list/agenda-list.component'; +import { AgendaListComponent } from './components/agenda-list/agenda-list.component'; +import { TopicDetailComponent } from './components/topic-detail/topic-detail.component'; +/** + * AppModule for the agenda and it's children. + */ @NgModule({ imports: [CommonModule, AgendaRoutingModule, SharedModule], - declarations: [AgendaListComponent] + declarations: [AgendaListComponent, TopicDetailComponent] }) export class AgendaModule {} diff --git a/client/src/app/site/agenda/agenda-list/agenda-list.component.css b/client/src/app/site/agenda/components/agenda-list/agenda-list.component.css similarity index 100% rename from client/src/app/site/agenda/agenda-list/agenda-list.component.css rename to client/src/app/site/agenda/components/agenda-list/agenda-list.component.css diff --git a/client/src/app/site/agenda/agenda-list/agenda-list.component.html b/client/src/app/site/agenda/components/agenda-list/agenda-list.component.html similarity index 100% rename from client/src/app/site/agenda/agenda-list/agenda-list.component.html rename to client/src/app/site/agenda/components/agenda-list/agenda-list.component.html diff --git a/client/src/app/site/agenda/agenda-list/agenda-list.component.spec.ts b/client/src/app/site/agenda/components/agenda-list/agenda-list.component.spec.ts similarity index 91% rename from client/src/app/site/agenda/agenda-list/agenda-list.component.spec.ts rename to client/src/app/site/agenda/components/agenda-list/agenda-list.component.spec.ts index 652f5b1a8..66cdb9fdd 100644 --- a/client/src/app/site/agenda/agenda-list/agenda-list.component.spec.ts +++ b/client/src/app/site/agenda/components/agenda-list/agenda-list.component.spec.ts @@ -1,7 +1,7 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { AgendaListComponent } from './agenda-list.component'; -import { E2EImportsModule } from '../../../../e2e-imports.module'; +import { E2EImportsModule } from '../../../../../e2e-imports.module'; describe('AgendaListComponent', () => { let component: AgendaListComponent; diff --git a/client/src/app/site/agenda/agenda-list/agenda-list.component.ts b/client/src/app/site/agenda/components/agenda-list/agenda-list.component.ts similarity index 60% rename from client/src/app/site/agenda/agenda-list/agenda-list.component.ts rename to client/src/app/site/agenda/components/agenda-list/agenda-list.component.ts index b023d6091..cb7197516 100644 --- a/client/src/app/site/agenda/agenda-list/agenda-list.component.ts +++ b/client/src/app/site/agenda/components/agenda-list/agenda-list.component.ts @@ -1,12 +1,13 @@ import { Component, OnInit } from '@angular/core'; +import { Router, ActivatedRoute } from '@angular/router'; import { Title } from '@angular/platform-browser'; -import { TranslateService } from '@ngx-translate/core'; -import { ViewItem } from '../models/view-item'; -import { ListViewBaseComponent } from '../../base/list-view-base'; -import { AgendaRepositoryService } from '../services/agenda-repository.service'; -import { Router } from '@angular/router'; import { MatSnackBar } from '@angular/material'; +import { TranslateService } from '@ngx-translate/core'; +import { ViewItem } from '../../models/view-item'; +import { ListViewBaseComponent } from 'app/site/base/list-view-base'; +import { AgendaRepositoryService } from '../../services/agenda-repository.service'; + /** * List view for the agenda. * @@ -20,16 +21,18 @@ import { MatSnackBar } from '@angular/material'; export class AgendaListComponent extends ListViewBaseComponent implements OnInit { /** * The usual constructor for components - * @param titleService - * @param translate - * @param matSnackBar - * @param router - * @param repo + * @param titleService Setting the browser tab title + * @param translate translations + * @param matSnackBar Shows errors and messages + * @param route Angulars ActivatedRoute + * @param router Angulars router + * @param repo the agenda repository */ public constructor( titleService: Title, translate: TranslateService, matSnackBar: MatSnackBar, + private route: ActivatedRoute, private router: Router, private repo: AgendaRepositoryService ) { @@ -51,13 +54,14 @@ export class AgendaListComponent extends ListViewBaseComponent impleme /** * Handler for click events on agenda item rows * Links to the content object if any + * + * Gets content object from the repository rather than from the model + * to avoid race conditions + * @param item the item that was selected from the list view */ public selectAgendaItem(item: ViewItem): void { - if (item.contentObject) { - this.router.navigate([item.contentObject.getDetailStateURL()]); - } else { - console.error(`The selected item ${item} has no content object`); - } + const contentObject = this.repo.getContentObject(item.item); + this.router.navigate([contentObject.getDetailStateURL()]); } /** @@ -65,6 +69,6 @@ export class AgendaListComponent extends ListViewBaseComponent impleme * Comes from the HeadBar Component */ public onPlusButton(): void { - console.log('create new motion'); + this.router.navigate(['topics/new'], { relativeTo: this.route }); } } diff --git a/client/src/app/site/agenda/components/speaker-list/speaker-list.component.html b/client/src/app/site/agenda/components/speaker-list/speaker-list.component.html new file mode 100644 index 000000000..2c78480ef --- /dev/null +++ b/client/src/app/site/agenda/components/speaker-list/speaker-list.component.html @@ -0,0 +1,116 @@ + + +
+

+ {{ viewItem.getTitle() }}: List of speakers +

+
+
+ + + + + + Last speakers + + + + +
+ {{ number + 1 }} . {{ speaker }} +
+
+ + + +  ( Start time : {{ speaker.begin_time }}) +
+ +
+
+
+ + +
+ play_arrow + {{ activeSpeaker }} + + +
+ + +
+
+ + + +
+ + + mic + Start + + + {{ item.marked ? 'star' : 'star_border' }} + + + close + + +
+
+
+
+
+ + +
+
+ +
+
+ + +
+
+ + + +
+
+
diff --git a/client/src/app/site/agenda/components/speaker-list/speaker-list.component.scss b/client/src/app/site/agenda/components/speaker-list/speaker-list.component.scss new file mode 100644 index 000000000..a74d4972f --- /dev/null +++ b/client/src/app/site/agenda/components/speaker-list/speaker-list.component.scss @@ -0,0 +1,63 @@ +.speaker-card { + margin: 0 20px 0 20px; + padding: 0; + + .finished-list { + margin-bottom: 15px; + .finished-suffix { + color: slategray; + font-size: 80%; + margin-right: 10px; + } + + .mat-list-item { + height: auto; + } + + button { + margin-left: 10px; + } + } + + .current-speaker { + padding: 10px 25px 15px 25px; + display: table; + .speaking-icon { + display: table-cell; + vertical-align: middle; + } + + .speaking-name { + display: table-cell; + vertical-align: middle; + font-weight: bold; + padding-left: 10px; + } + + button { + display: table-cell; + vertical-align: middle; + margin-left: 10px; + } + } + + .waiting-list { + padding: 10px 25px 0 25px; + } + + form { + padding: 15px 25px 10px 25px; + width: auto; + + .search-users { + display: grid; + .mat-form-field { + width: 100%; + } + } + } + + .add-self-buttons { + padding: 0 0 20px 25px; + } +} diff --git a/client/src/app/site/agenda/components/speaker-list/speaker-list.component.spec.ts b/client/src/app/site/agenda/components/speaker-list/speaker-list.component.spec.ts new file mode 100644 index 000000000..0ac98de24 --- /dev/null +++ b/client/src/app/site/agenda/components/speaker-list/speaker-list.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SpeakerListComponent } from './speaker-list.component'; +import { E2EImportsModule } from '../../../../../e2e-imports.module'; + +describe('SpeakerListComponent', () => { + let component: SpeakerListComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SpeakerListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/agenda/components/speaker-list/speaker-list.component.ts b/client/src/app/site/agenda/components/speaker-list/speaker-list.component.ts new file mode 100644 index 000000000..f4142b538 --- /dev/null +++ b/client/src/app/site/agenda/components/speaker-list/speaker-list.component.ts @@ -0,0 +1,171 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { ViewSpeaker, SpeakerState } from '../../models/view-speaker'; +import { User } from 'app/shared/models/users/user'; +import { FormGroup, FormControl } from '@angular/forms'; +import { BehaviorSubject } from 'rxjs'; +import { DataStoreService } from 'app/core/services/data-store.service'; +import { AgendaRepositoryService } from '../../services/agenda-repository.service'; +import { ViewItem } from '../../models/view-item'; +import { OperatorService } from 'app/core/services/operator.service'; + +/** + * The list of speakers for agenda items. + */ +@Component({ + selector: 'os-speaker-list', + templateUrl: './speaker-list.component.html', + styleUrls: ['./speaker-list.component.scss'] +}) +export class SpeakerListComponent implements OnInit { + /** + * Holds the view item to the given topic + */ + public viewItem: ViewItem; + + /** + * Holds the speakers + */ + public speakers: ViewSpeaker[]; + + /** + * Holds the active speaker + */ + public activeSpeaker: ViewSpeaker; + + /** + * Holds the speakers who were marked done + */ + public finishedSpeakers: ViewSpeaker[]; + + /** + * Hold the users + */ + public users: BehaviorSubject; + + /** + * Required for the user search selector + */ + public addSpeakerForm: FormGroup; + + /** + * Constructor for speaker list component + * @param route Angulars ActivatedRoute + * @param DS the DataStore + * @param itemRepo Repository fpr agenda items + * @param op the current operator + */ + public constructor( + private route: ActivatedRoute, + private DS: DataStoreService, + private itemRepo: AgendaRepositoryService, + private op: OperatorService + ) { + this.addSpeakerForm = new FormGroup({ user_id: new FormControl([]) }); + this.getAgendaItemByUrl(); + } + + /** + * Init. + * + * Observe users, + * React to form changes + */ + public ngOnInit(): void { + // load and observe users + this.users = new BehaviorSubject(this.DS.getAll(User)); + this.DS.changeObservable.subscribe(model => { + if (model instanceof User) { + this.users.next(this.DS.getAll(User)); + } + }); + + // detect changes in the 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); + } + }); + } + + /** + * Extract the ID from the url + * Determine whether the speaker list belongs to a motion or a topic + */ + public getAgendaItemByUrl(): void { + const id = +this.route.snapshot.url[0]; + + this.itemRepo.getViewModelObservable(id).subscribe(newAgendaItem => { + if (newAgendaItem) { + this.viewItem = newAgendaItem; + + const allSpeakers = this.itemRepo.createViewSpeakers(newAgendaItem.item); + + this.speakers = allSpeakers.filter(speaker => speaker.state === SpeakerState.WAITING); + this.finishedSpeakers = allSpeakers.filter(speaker => speaker.state === SpeakerState.FINISHED); + this.activeSpeaker = allSpeakers.find(speaker => speaker.state === SpeakerState.CURRENT); + } + }); + } + + /** + * 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 async addNewSpeaker(userId?: number): Promise { + await this.itemRepo.addSpeaker(userId, this.viewItem.item); + this.addSpeakerForm.reset(); + } + + /** + * React to manual in the sorting order. + * Informs the repo about changes in the order + * @param listInNewOrder Contains the newly ordered list of ViewSpeakers + */ + public onSortingChange(listInNewOrder: ViewSpeaker[]): void { + // extract the ids from the ViewSpeaker array + const userIds = listInNewOrder.map(speaker => speaker.id); + this.itemRepo.sortSpeakers(userIds, this.viewItem.item); + } + + /** + * Click on the mic button to mark a speaker as speaking + * @param item the speaker marked in the list + */ + public onStartButton(item: ViewSpeaker): void { + this.itemRepo.startSpeaker(item.id, this.viewItem.item); + } + + /** + * Click on the mic-cross button + */ + public onStopButton(): void { + this.itemRepo.stopSpeaker(this.viewItem.item); + } + + /** + * Click on the star button + * @param item + */ + public onMarkButton(item: ViewSpeaker): void { + this.itemRepo.markSpeaker(item.user.id, !item.marked, this.viewItem.item); + } + + /** + * Click on the X button + * @param item + */ + public onDeleteButton(item?: ViewSpeaker): void { + this.itemRepo.deleteSpeaker(this.viewItem.item, item ? item.id : null); + } + + /** + * 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.op.user.id); + } +} diff --git a/client/src/app/site/agenda/components/topic-detail/topic-detail.component.html b/client/src/app/site/agenda/components/topic-detail/topic-detail.component.html new file mode 100644 index 000000000..e6ede53a2 --- /dev/null +++ b/client/src/app/site/agenda/components/topic-detail/topic-detail.component.html @@ -0,0 +1,83 @@ + + +
+

+ New + Edit + +   Topic +

+
+ + + +
+ +
+
+

{{ topic.title }}

+

{{ topicForm.get('title').value }}

+
+ + +
+ + {{ topic.text }} +
No description provided.
+
+
+ +
+

+ Attachments : + +

+
+ +
+
+ + + A name is required + +
+ +
+ + + +
+ + +
+
+
+ + + + + + diff --git a/client/src/app/site/agenda/components/topic-detail/topic-detail.component.scss b/client/src/app/site/agenda/components/topic-detail/topic-detail.component.scss new file mode 100644 index 000000000..60a81d9f5 --- /dev/null +++ b/client/src/app/site/agenda/components/topic-detail/topic-detail.component.scss @@ -0,0 +1,26 @@ +.topic-container { + max-width: 1200px; +} + +.topic-title { + padding: 40px; + padding-left: 25px; + line-height: 180%; + font-size: 120%; + color: #317796; // TODO: put in theme as $primary + + h2 { + margin: 0; + font-weight: normal; + } +} + +.topic-text { + margin: 0 20px 0 20px; + padding: 25px; + + .missing { + color: slategray; // TODO: Colors in theme + font-style: italic; + } +} diff --git a/client/src/app/site/agenda/components/topic-detail/topic-detail.component.spec.ts b/client/src/app/site/agenda/components/topic-detail/topic-detail.component.spec.ts new file mode 100644 index 000000000..c8e4beb97 --- /dev/null +++ b/client/src/app/site/agenda/components/topic-detail/topic-detail.component.spec.ts @@ -0,0 +1,26 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TopicDetailComponent } from './topic-detail.component'; +import { E2EImportsModule } from '../../../../../e2e-imports.module'; + +describe('TopicComponent', () => { + let component: TopicDetailComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [TopicDetailComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TopicDetailComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/agenda/components/topic-detail/topic-detail.component.ts b/client/src/app/site/agenda/components/topic-detail/topic-detail.component.ts new file mode 100644 index 000000000..cb03e75c6 --- /dev/null +++ b/client/src/app/site/agenda/components/topic-detail/topic-detail.component.ts @@ -0,0 +1,185 @@ +import { Component } from '@angular/core'; +import { Location } from '@angular/common'; +import { FormGroup, Validators, FormBuilder } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { TranslateService } from '@ngx-translate/core'; + +import { PromptService } from 'app/core/services/prompt.service'; +import { TopicRepositoryService } from '../../services/topic-repository.service'; +import { ViewTopic } from '../../models/view-topic'; + +/** + * Detail page for topics. + */ +@Component({ + selector: 'os-topic-detail', + templateUrl: './topic-detail.component.html', + styleUrls: ['./topic-detail.component.scss'] +}) +export class TopicDetailComponent { + /** + * Determine if the topic is in edit mode + */ + public editTopic: boolean; + + /** + * Determine is created + */ + public newTopic: boolean; + + /** + * Holds the current view topic + */ + public topic: ViewTopic; + + /** + * Topic form + */ + public topicForm: FormGroup; + + /** + * Constructor for the topic detail page. + * + * @param route Angulars ActivatedRoute + * @param router Angulars Router + * @param location Enables to navigate back + * @param formBuilder Angulars FormBuilder + * @param translate Handles translations + * @param repo The topic repository + * @param promptService Allows warning before deletion attempts + */ + public constructor( + private route: ActivatedRoute, + private router: Router, + private location: Location, + private formBuilder: FormBuilder, + private translate: TranslateService, + private repo: TopicRepositoryService, + private promptService: PromptService + ) { + this.getTopicByUrl(); + this.createForm(); + } + + /** + * Set the edit mode to the given Status + * @param mode + */ + public setEditMode(mode: boolean): void { + this.editTopic = mode; + if (mode) { + this.patchForm(); + } + if (!mode && this.newTopic) { + this.router.navigate(['./agenda/']); + } + } + + /** + * Save a new topic as agenda item + */ + public async saveTopic(): Promise { + if (this.newTopic && this.topicForm.valid) { + const response = await this.repo.create(this.topicForm.value); + this.router.navigate([`/agenda/topics/${response.id}`]); + // after creating a new topic, go "back" to agenda list view + this.location.replaceState('/agenda/'); + } else { + await this.repo.update(this.topicForm.value, this.topic); + this.setEditMode(false); + } + } + + /** + * Setup the form to create or alter the topic + */ + public createForm(): void { + this.topicForm = this.formBuilder.group({ + title: ['', Validators.required], + text: [''] + }); + } + + /** + * Overwrite form Values with values from the topic + */ + public patchForm(): void { + const topicPatch = {}; + Object.keys(this.topicForm.controls).forEach(ctrl => { + topicPatch[ctrl] = this.topic[ctrl]; + }); + this.topicForm.patchValue(topicPatch); + } + + /** + * Determine whether a new topic should be created or an existing one should + * be loaded using the ID from the URL + */ + public getTopicByUrl(): void { + if (this.route.snapshot.url[1] && this.route.snapshot.url[1].path === 'new') { + // creates a new topic + this.newTopic = true; + this.editTopic = true; + this.topic = new ViewTopic(); + } else { + // load existing topic + this.route.params.subscribe(params => { + this.loadTopic(params.id); + }); + } + } + + /** + * Loads a top from the repository + * @param id the id of the required topic + */ + public loadTopic(id: number): void { + this.repo.getViewModelObservable(id).subscribe(newViewTopic => { + // repo sometimes delivers undefined values + // also ensures edition cannot be interrupted by autoupdate + if (newViewTopic && !this.editTopic) { + this.topic = newViewTopic; + // personalInfoForm is undefined during 'new' and directly after reloading + if (this.topicForm) { + this.patchForm(); + } + } + }); + } + + /** + * Create the absolute path to the corresponding list of speakers + * + * @returns the link to the list of speakers as string + */ + public getSpeakerLink(): string { + if (!this.newTopic && this.topic) { + const item = this.repo.getAgendaItem(this.topic.topic); + if (item) { + return `/agenda/${item.id}/speakers`; + } + } + } + + /** + * Handler for the delete button. Uses the PromptService + */ + public async onDeleteButton(): Promise { + const content = this.translate.instant('Delete') + ` ${this.topic.title}?`; + if (await this.promptService.open('Are you sure?', content)) { + await this.repo.delete(this.topic); + this.router.navigate(['/agenda']); + } + } + + /** + * Hitting escape while in topicForm should cancel editing + * @param event has the code + */ + public keyDownFunction(event: KeyboardEvent): void { + if (event.keyCode === 27) { + this.setEditMode(false); + } + } +} diff --git a/client/src/app/site/agenda/models/view-speaker.ts b/client/src/app/site/agenda/models/view-speaker.ts new file mode 100644 index 000000000..8584abdb6 --- /dev/null +++ b/client/src/app/site/agenda/models/view-speaker.ts @@ -0,0 +1,89 @@ +import { BaseViewModel } from 'app/site/base/base-view-model'; +import { Speaker } from 'app/shared/models/agenda/speaker'; +import { User } from 'app/shared/models/users/user'; +import { Selectable } from 'app/shared/components/selectable'; + +/** + * Determine the state of the speaker + */ +export enum SpeakerState { + WAITING, + CURRENT, + FINISHED +} + +/** + * Provides "safe" access to a speaker with all it's components + */ +export class ViewSpeaker extends BaseViewModel implements Selectable { + private _speaker: Speaker; + private _user: User; + + public get speaker(): Speaker { + return this._speaker; + } + + public get user(): User { + return this._user; + } + + public get id(): number { + return this.speaker ? this.speaker.id : null; + } + + public get weight(): number { + return this.speaker ? this.speaker.weight : null; + } + + public get marked(): boolean { + return this.speaker ? this.speaker.marked : null; + } + + public get begin_time(): string { + return this.speaker ? this.speaker.begin_time : null; + } + + public get end_time(): string { + return this.speaker ? this.speaker.end_time : null; + } + + /** + * Returns: + * - waiting if there is no begin nor end time + * - current if there is a begin time and not end time + * - finished if there are both begin and end time + */ + public get state(): SpeakerState { + if (!this.begin_time && !this.end_time) { + return SpeakerState.WAITING; + } else if (this.begin_time && !this.end_time) { + return SpeakerState.CURRENT; + } else { + return SpeakerState.FINISHED; + } + } + + public get name(): string { + return this.user.full_name; + } + + public constructor(speaker?: Speaker, user?: User) { + super(); + this._speaker = speaker; + this._user = user; + } + + public getTitle(): string { + return this.name; + } + + /** + * Speaker is not a base model, + * @param update the incoming update + */ + public updateValues(update: Speaker): void { + if (this.id === update.id) { + this._speaker = update; + } + } +} diff --git a/client/src/app/site/agenda/models/view-topic.ts b/client/src/app/site/agenda/models/view-topic.ts new file mode 100644 index 000000000..257f8adf4 --- /dev/null +++ b/client/src/app/site/agenda/models/view-topic.ts @@ -0,0 +1,58 @@ +import { BaseViewModel } from '../../base/base-view-model'; +import { Topic } from 'app/shared/models/topics/topic'; +import { Mediafile } from 'app/shared/models/mediafiles/mediafile'; +import { Item } from 'app/shared/models/agenda/item'; + +/** + * Provides "safe" access to topic with all it's components + */ +export class ViewTopic extends BaseViewModel { + private _topic: Topic; + private _attachments: Mediafile[]; + private _agenda_item: Item; + + public get topic(): Topic { + return this._topic; + } + + public get attachments(): Mediafile[] { + return this._attachments; + } + + public get agenda_item(): Item { + return this._agenda_item; + } + + public get id(): number { + return this.topic ? this.topic.id : null; + } + + public get agenda_item_id(): number { + return this.topic ? this.topic.agenda_item_id : null; + } + + public get title(): string { + return this.topic ? this.topic.title : null; + } + + public get text(): string { + return this.topic ? this.topic.text : null; + } + + public constructor(topic?: Topic, attachments?: Mediafile[], item?: Item) { + super(); + this._topic = topic; + this._attachments = attachments; + this._agenda_item = item; + } + + public getTitle(): string { + return this.title; + } + + public updateValues(update: Topic): void { + if (this.id === update.id) { + this._topic = update; + } + } +} diff --git a/client/src/app/site/agenda/services/agenda-repository.service.ts b/client/src/app/site/agenda/services/agenda-repository.service.ts index a747179ff..7c465c9a4 100644 --- a/client/src/app/site/agenda/services/agenda-repository.service.ts +++ b/client/src/app/site/agenda/services/agenda-repository.service.ts @@ -8,6 +8,10 @@ import { AgendaBaseModel } from '../../../shared/models/base/agenda-base-model'; import { BaseModel } from '../../../shared/models/base/base-model'; import { Identifiable } from '../../../shared/models/base/identifiable'; import { CollectionStringModelMapperService } from '../../../core/services/collectionStringModelMapper.service'; +import { ViewSpeaker } from '../models/view-speaker'; +import { Speaker } from 'app/shared/models/agenda/speaker'; +import { User } from 'app/shared/models/users/user'; +import { HttpService } from 'app/core/services/http.service'; /** * Repository service for users @@ -18,15 +22,27 @@ import { CollectionStringModelMapperService } from '../../../core/services/colle providedIn: 'root' }) export class AgendaRepositoryService extends BaseRepository { - public constructor(DS: DataStoreService, mapperService: CollectionStringModelMapperService) { + /** + * Contructor for agenda repository. + * @param DS The DataStore + * @param httpService OpenSlides own HttpService + * @param mapperService OpenSlides mapping service for collection strings + */ + public constructor( + protected DS: DataStoreService, + private httpService: HttpService, + mapperService: CollectionStringModelMapperService + ) { super(DS, mapperService, Item); } /** * Returns the corresponding content object to a given {@link Item} as an {@link AgendaBaseModel} - * @param agendaItem + * Used dynamically because of heavy race conditions + * @param agendaItem the target agenda Item + * @returns the content object of the given item. Might be null if it was not found. */ - private getContentObject(agendaItem: Item): AgendaBaseModel { + public getContentObject(agendaItem: Item): AgendaBaseModel { const contentObject = this.DS.get( agendaItem.content_object.collection, agendaItem.content_object.id @@ -45,6 +61,88 @@ export class AgendaRepositoryService extends BaseRepository { } } + /** + * Generate viewSpeaker objects from a given agenda Item + * @param item agenda Item holding speakers + * @returns the list of view speakers corresponding to the given item + */ + public createViewSpeakers(item: Item): ViewSpeaker[] { + let viewSpeakers = []; + const speakers = item.speakers; + if (speakers && speakers.length > 0) { + speakers.forEach((speaker: Speaker) => { + const user = this.DS.get(User, speaker.user_id); + viewSpeakers.push(new ViewSpeaker(speaker, user)); + }); + } + // sort speakers by their weight + viewSpeakers = viewSpeakers.sort((a, b) => a.weight - b.weight); + return viewSpeakers; + } + + /** + * Add a new speaker to an agenda item. + * Sends the users ID to the server + * + * Might need another repo + * @param id {@link User} id of the new speaker + * @param agenda the target agenda item + */ + public async addSpeaker(id: number, agenda: Item): Promise { + const restUrl = `rest/agenda/item/${agenda.id}/manage_speaker/`; + await this.httpService.post(restUrl, { user: id }); + } + + /** + * Sets the given speaker ID to Speak + * @param id the speakers id + * @param agenda the target agenda item + */ + public async startSpeaker(id: number, agenda: Item): Promise { + const restUrl = `rest/agenda/item/${agenda.id}/speak/`; + await this.httpService.put(restUrl, { speaker: id }); + } + + /** + * Stops the current speaker + * @param agenda the target agenda item + */ + public async stopSpeaker(agenda: Item): Promise { + const restUrl = `rest/agenda/item/${agenda.id}/speak/`; + await this.httpService.delete(restUrl); + } + + /** + * Marks the current speaker + * @param id {@link User} id of the new speaker + * @param mark determine if the user was marked or not + * @param agenda the target agenda item + */ + public async markSpeaker(id: number, mark: boolean, agenda: Item): Promise { + const restUrl = `rest/agenda/item/${agenda.id}/manage_speaker/`; + await this.httpService.patch(restUrl, { user: id, marked: mark }); + } + + /** + * Deletes the given speaker for the agenda + * @param id the speakers id + * @param agenda the target agenda item + */ + public async deleteSpeaker(agenda: Item, id?: number): Promise { + const restUrl = `rest/agenda/item/${agenda.id}/manage_speaker/`; + await this.httpService.delete(restUrl, { speaker: id }); + } + + /** + * Posts an (manually) sorted speaker list to the server + * @param ids array of speaker id numbers + * @param Item the target agenda item + */ + public async sortSpeakers(ids: number[], agenda: Item): Promise { + const restUrl = `rest/agenda/item/${agenda.id}/sort_speakers/`; + await this.httpService.post(restUrl, { speakers: ids }); + } + /** * @ignore * @@ -72,9 +170,13 @@ export class AgendaRepositoryService extends BaseRepository { return null; } + /** + * Creates the viewItem out of a given item + * @param item the item that should be converted to view item + * @returns a new view item + */ public createViewModel(item: Item): ViewItem { const contentObject = this.getContentObject(item); - return new ViewItem(item, contentObject); } } diff --git a/client/src/app/site/agenda/services/topic-repository.service.spec.ts b/client/src/app/site/agenda/services/topic-repository.service.spec.ts new file mode 100644 index 000000000..2440aaf3a --- /dev/null +++ b/client/src/app/site/agenda/services/topic-repository.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { TopicRepositoryService } from './topic-repository.service'; +import { E2EImportsModule } from '../../../../e2e-imports.module'; + +describe('TopicRepositoryService', () => { + beforeEach(() => + TestBed.configureTestingModule({ + imports: [E2EImportsModule] + })); + + it('should be created', () => { + const service: TopicRepositoryService = TestBed.get(TopicRepositoryService); + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/agenda/services/topic-repository.service.ts b/client/src/app/site/agenda/services/topic-repository.service.ts new file mode 100644 index 000000000..f86869871 --- /dev/null +++ b/client/src/app/site/agenda/services/topic-repository.service.ts @@ -0,0 +1,89 @@ +import { Injectable } from '@angular/core'; + +import { Topic } from 'app/shared/models/topics/topic'; +import { BaseRepository } from 'app/site/base/base-repository'; +import { Mediafile } from 'app/shared/models/mediafiles/mediafile'; +import { Item } from 'app/shared/models/agenda/item'; +import { DataStoreService } from 'app/core/services/data-store.service'; +import { DataSendService } from 'app/core/services/data-send.service'; +import { ViewTopic } from '../models/view-topic'; +import { Identifiable } from 'app/shared/models/base/identifiable'; +import { CollectionStringModelMapperService } from 'app/core/services/collectionStringModelMapper.service'; + +/** + * Repository for topics + */ +@Injectable({ + providedIn: 'root' +}) +export class TopicRepositoryService extends BaseRepository { + /** + * Constructor calls the parent constructor + * + * @param DS Access the DataStore + * @param mapperService OpenSlides mapping service for collections + * @param dataSend Access the DataSendService + */ + public constructor( + DS: DataStoreService, + mapperService: CollectionStringModelMapperService, + private dataSend: DataSendService + ) { + super(DS, mapperService, Topic, [Mediafile, Item]); + } + + /** + * Creates a new viewModel out of the given model + * @param topic The topic that shall be converted into a view topic + * @returns a new view topic + */ + public createViewModel(topic: Topic): ViewTopic { + const attachments = this.DS.getMany(Mediafile, topic.attachments_id); + const item = this.getAgendaItem(topic); + return new ViewTopic(topic, attachments, item); + } + + /** + * Gets the corresponding agendaItem to the topic. + * Used to deal with race conditions + * + * @param topic the topic for the agenda item + * @returns an agenda item that fits for the topic + */ + public getAgendaItem(topic: Topic): Item { + return this.DS.get(Item, topic.agenda_item_id); + } + + /** + * Save a new topic + * @param topicData Partial topic data to be created + * @returns an Identifiable (usually id) as promise + */ + public async create(topicData: Partial): Promise { + const newTopic = new Topic(); + newTopic.patchValues(topicData); + return await this.dataSend.createModel(newTopic); + } + + /** + * Change an existing topic + * + * @param updateData form value containing the data meant to update the topic + * @param viewTopic the topic that should receive the update + */ + public async update(updateData: Partial, viewTopic: ViewTopic): Promise { + const updateTopic = new Topic(); + updateTopic.patchValues(viewTopic.topic); + updateTopic.patchValues(updateData); + + return await this.dataSend.updateModel(updateTopic); + } + + /** + * Delete a topic + * @param viewTopic the topic that should be removed + */ + public async delete(viewTopic: ViewTopic): Promise { + return await this.dataSend.deleteModel(viewTopic.topic); + } +} diff --git a/client/src/app/site/motions/components/motion-detail/motion-detail.component.html b/client/src/app/site/motions/components/motion-detail/motion-detail.component.html index 9eb6babf1..e1d55fb1b 100644 --- a/client/src/app/site/motions/components/motion-detail/motion-detail.component.html +++ b/client/src/app/site/motions/components/motion-detail/motion-detail.component.html @@ -39,20 +39,31 @@
- - - - +
+ + + + + + + + + +
+
diff --git a/client/src/app/site/motions/components/motion-detail/motion-detail.component.ts b/client/src/app/site/motions/components/motion-detail/motion-detail.component.ts index 26f5d0978..dcb394bc5 100644 --- a/client/src/app/site/motions/components/motion-detail/motion-detail.component.ts +++ b/client/src/app/site/motions/components/motion-detail/motion-detail.component.ts @@ -318,21 +318,21 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit { } /** - * Trigger to delete the motion - * - * TODO: Repo should handle + * Trigger to delete the motion. + * Sends a delete request over the repository and + * shows a "are you sure" dialog */ - public deleteMotionButton(): void { - this.repo.delete(this.motion).then(() => { - this.router.navigate(['./motions/']); - }, this.raiseError); - // TODO: this needs to be in the autoupdate code. - /*const motList = this.categoryRepo.getMotionsOfCategory(this.motion.category); - const index = motList.indexOf(this.motion.motion, 0); - if (index > -1) { - motList.splice(index, 1); - } - this.categoryRepo.updateCategoryNumbering(this.motion.category, motList);*/ + public async deleteMotionButton(): Promise { + await this.repo.delete(this.motion).then(); + this.router.navigate(['./motions/']); + + // This should happen during auto update + // const motList = this.categoryRepo.getMotionsOfCategory(this.motion.category); + // const index = motList.indexOf(this.motion.motion, 0); + // if (index > -1) { + // motList.splice(index, 1); + // } + // this.categoryRepo.updateCategoryNumbering(this.motion.category, motList); } /** @@ -415,7 +415,6 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit { } /** - * Init. Does nothing here. * Comes from the head bar * @param mode */ @@ -492,6 +491,14 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit { }); } + /** + * Create the absolute path to the corresponding list of speakers + * @returns the link to the corresponding list of speakers as string + */ + public getSpeakerLink(): string { + return `/agenda/${this.motion.agenda_item_id}/speakers`; + } + /** * Determine if the user has the correct requirements to alter the motion */ diff --git a/client/src/app/site/motions/models/view-motion.ts b/client/src/app/site/motions/models/view-motion.ts index f137c0a51..5cf2eeaf1 100644 --- a/client/src/app/site/motions/models/view-motion.ts +++ b/client/src/app/site/motions/models/view-motion.ts @@ -88,6 +88,10 @@ export class ViewMotion extends BaseViewModel { return this._category; } + public get agenda_item_id(): number { + return this.motion ? this.motion.agenda_item_id : null; + } + public get category_id(): number { return this.motion && this.category ? this.motion.category_id : null; } diff --git a/client/src/app/site/motions/motions-routing.module.ts b/client/src/app/site/motions/motions-routing.module.ts index 90228594d..2d5b26e16 100644 --- a/client/src/app/site/motions/motions-routing.module.ts +++ b/client/src/app/site/motions/motions-routing.module.ts @@ -5,6 +5,7 @@ import { MotionDetailComponent } from './components/motion-detail/motion-detail. import { CategoryListComponent } from './components/category-list/category-list.component'; import { MotionCommentSectionListComponent } from './components/motion-comment-section-list/motion-comment-section-list.component'; import { StatuteParagraphListComponent } from './components/statute-paragraph-list/statute-paragraph-list.component'; +import { SpeakerListComponent } from '../agenda/components/speaker-list/speaker-list.component'; const routes: Routes = [ { path: '', component: MotionListComponent }, @@ -12,7 +13,8 @@ const routes: Routes = [ { path: 'comment-section', component: MotionCommentSectionListComponent }, { path: 'statute-paragraphs', component: StatuteParagraphListComponent }, { path: 'new', component: MotionDetailComponent }, - { path: ':id', component: MotionDetailComponent } + { path: ':id', component: MotionDetailComponent }, + { path: ':id/speakers', component: SpeakerListComponent } ]; @NgModule({ diff --git a/client/src/assets/styles/openslides-theme.scss b/client/src/assets/styles/openslides-theme.scss index 4e9500f9c..14bd520ad 100644 --- a/client/src/assets/styles/openslides-theme.scss +++ b/client/src/assets/styles/openslides-theme.scss @@ -32,10 +32,45 @@ $openslides-blue: ( ) ); +$openslides-green: ( + 50: #e9f2e6, + 100: #c8e0bf, + 200: #a3cb95, + 300: #7eb66b, + 400: #62a64b, + 500: #46962b, + 600: #3f8e26, + 700: #0a321e, + 800: #092d1a, + 900: #072616, + A100: #acff9d, + A200: #80ff6a, + A400: #55ff37, + A700: #3fff1e, + contrast: ( + 50: #000000, + 100: #000000, + 200: #000000, + 300: #000000, + 400: #000000, + 500: #ffffff, + 600: #ffffff, + 700: #ffffff, + 800: #ffffff, + 900: #ffffff, + A100: #000000, + A200: #000000, + A400: #000000, + A700: #000000 + ) +); + // Generate paletes using: https://material.io/design/color/ // default values fir mat-palette: $default: 500, $lighter: 100, $darker: 700. $openslides-primary: mat-palette($openslides-blue); -$openslides-accent: mat-palette($mat-blue); +$openslides-accent: mat-palette($mat-light-blue); +// $openslides-primary: mat-palette($openslides-green); +// $openslides-accent: mat-palette($mat-amber); $openslides-warn: mat-palette($mat-red);