Create agenda topics
Also: Enhanced DragNDrop Component List Of Speakers
This commit is contained in:
parent
080b6f52ad
commit
55d279ca4e
@ -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<T>(url: string, method: HTTPMethod, data?: any): Promise<T> {
|
||||
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;
|
||||
}
|
||||
|
@ -1,10 +1,17 @@
|
||||
<div cdkDropList class="list" (cdkDropListDropped)="drop($event)">
|
||||
<div class="box line" *ngFor="let item of array" cdkDrag>
|
||||
<div class="box line" *ngFor="let item of array; let i = index" cdkDrag>
|
||||
<div class="section-one" cdkDragHandle>
|
||||
<mat-icon>drag_handle</mat-icon>
|
||||
<!-- TODO: drag_indicator after icon update -->
|
||||
<mat-icon>unfold_more</mat-icon>
|
||||
</div>
|
||||
<div class="section-two">
|
||||
{{item}}
|
||||
<!-- {number}. {item.toString()} -->
|
||||
<span *ngIf="count">{{ i+1 }}. </span>
|
||||
<span>{{ item }}</span>
|
||||
</div>
|
||||
<div class="section-three">
|
||||
<!-- Extra controls slot using implicit template references -->
|
||||
<ng-template [ngTemplateOutlet]="templateRef" [ngTemplateOutletContext]="{ $implicit: item }"></ng-template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
* <os-sorting-list
|
||||
* [input]="listOfSelectables">
|
||||
* [input]="listOfSelectables"
|
||||
* [live]="true"
|
||||
* [count]="true"
|
||||
* (sortEvent)="onSortingChange($event)">
|
||||
* </os-sorting-list>
|
||||
* ```
|
||||
*
|
||||
@ -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<Selectable>;
|
||||
|
||||
public array: Array<Selectable>;
|
||||
|
||||
/**
|
||||
* Empty constructor
|
||||
* Declare the templateRef to coexist between parent in child
|
||||
*/
|
||||
public constructor(public translate: TranslateService) {}
|
||||
@ContentChild(TemplateRef)
|
||||
public templateRef: TemplateRef<Selectable>;
|
||||
|
||||
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
|
||||
* <os-sorting-list ... [live]="true" (sortEvent)="onSortingChange($event)">
|
||||
* ```
|
||||
*/
|
||||
@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<Selectable>) {
|
||||
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<Array<Selectable>>();
|
||||
|
||||
/**
|
||||
* 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<Selectable[]>): void {
|
||||
moveItemInArray(this.array, event.previousIndex, event.currentIndex);
|
||||
this.sortEvent.emit(this.array);
|
||||
}
|
||||
}
|
||||
|
@ -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 '';
|
||||
}
|
||||
}
|
||||
|
@ -25,6 +25,6 @@ export class Topic extends AgendaBaseModel {
|
||||
}
|
||||
|
||||
public getDetailStateURL(): string {
|
||||
return 'TODO';
|
||||
return `/agenda/topics/${this.id}`;
|
||||
}
|
||||
}
|
||||
|
@ -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 },
|
||||
|
@ -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)],
|
||||
|
@ -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 {}
|
||||
|
@ -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;
|
@ -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<ViewItem> 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<ViewItem> 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<ViewItem> impleme
|
||||
* Comes from the HeadBar Component
|
||||
*/
|
||||
public onPlusButton(): void {
|
||||
console.log('create new motion');
|
||||
this.router.navigate(['topics/new'], { relativeTo: this.route });
|
||||
}
|
||||
}
|
@ -0,0 +1,116 @@
|
||||
<os-head-bar [nav]="false" [goBack]="true">
|
||||
<!-- Title -->
|
||||
<div class="title-slot">
|
||||
<h2>
|
||||
<span *ngIf="viewItem">{{ viewItem.getTitle() }}:</span> <span translate> List of speakers </span>
|
||||
</h2>
|
||||
</div>
|
||||
</os-head-bar>
|
||||
|
||||
<mat-card class="speaker-card">
|
||||
<!-- List of finished speakers -->
|
||||
<mat-expansion-panel *ngIf="finishedSpeakers && finishedSpeakers.length > 0" class="finished-list">
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title translate> Last speakers </mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<mat-list>
|
||||
<!-- {Number}. {full_name} {time} minutes (Start time: {begin_time}) [close button] -->
|
||||
<mat-list-item *ngFor="let speaker of finishedSpeakers; let number = index">
|
||||
<div class="finished-prefix">
|
||||
<span>{{ number + 1 }}</span> <span>. {{ speaker }}</span>
|
||||
</div>
|
||||
<div class="finished-suffix">
|
||||
<!-- TODO: No Date or time class yet -->
|
||||
<!-- <span> calc time here </span> -->
|
||||
<!-- <span translate>minutes</span> -->
|
||||
<span> (</span> <span translate>Start time</span> <span>: {{ speaker.begin_time }})</span>
|
||||
</div>
|
||||
<button
|
||||
mat-stroked-button
|
||||
matTooltip="{{ 'Remove' | translate }}"
|
||||
*osPerms="'agenda.can_manage_list_of_speakers'"
|
||||
(click)="onDeleteButton(speaker)"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</mat-list-item>
|
||||
</mat-list>
|
||||
</mat-expansion-panel>
|
||||
|
||||
<!-- Current Speaker -->
|
||||
<div class="current-speaker" *ngIf="activeSpeaker">
|
||||
<mat-icon class="speaking-icon">play_arrow</mat-icon>
|
||||
<span class="speaking-name">{{ activeSpeaker }}</span>
|
||||
|
||||
<button
|
||||
mat-stroked-button
|
||||
matTooltip="{{ 'End speech' | translate }}"
|
||||
*osPerms="'agenda.can_manage_list_of_speakers'"
|
||||
(click)="onStopButton()"
|
||||
>
|
||||
<mat-icon>mic_off</mat-icon>
|
||||
<span translate>Stop</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Waiting speakers -->
|
||||
<div *osPerms="'agenda.can_manage_list_of_speakers'">
|
||||
<div class="waiting-list" *ngIf="speakers && speakers.length > 0">
|
||||
<os-sorting-list [input]="speakers" [live]="true" [count]="true" (sortEvent)="onSortingChange($event)">
|
||||
<!-- implicit item references into the component using ng-template slot -->
|
||||
<ng-template let-item>
|
||||
<div class="speak-action-buttons">
|
||||
<mat-button-toggle-group>
|
||||
<mat-button-toggle
|
||||
matTooltip="{{ 'Begin speech' | translate }}"
|
||||
(click)="onStartButton(item)"
|
||||
>
|
||||
<mat-icon>mic</mat-icon>
|
||||
<span translate>Start</span>
|
||||
</mat-button-toggle>
|
||||
<mat-button-toggle
|
||||
matTooltip="{{ 'Mark speaker' | translate }}"
|
||||
(click)="onMarkButton(item)"
|
||||
>
|
||||
<mat-icon>{{ item.marked ? 'star' : 'star_border' }}</mat-icon>
|
||||
</mat-button-toggle>
|
||||
<mat-button-toggle matTooltip="{{ 'Remove' | translate }}" (click)="onDeleteButton(item)">
|
||||
<mat-icon>close</mat-icon>
|
||||
</mat-button-toggle>
|
||||
</mat-button-toggle-group>
|
||||
</div>
|
||||
</ng-template>
|
||||
</os-sorting-list>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search for speakers -->
|
||||
<div *osPerms="'agenda.can_manage_list_of_speakers'">
|
||||
<form *ngIf="users && users.value.length > 0" [formGroup]="addSpeakerForm">
|
||||
<os-search-value-selector
|
||||
class="search-users"
|
||||
ngDefaultControl
|
||||
[form]="addSpeakerForm"
|
||||
[formControl]="addSpeakerForm.get('user_id')"
|
||||
[multiple]="false"
|
||||
listname="{{ 'Select or search new speaker ...' | translate }}"
|
||||
[InputListValues]="users"
|
||||
></os-search-value-selector>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Add me and remove me if OP has correct permission -->
|
||||
<div *osPerms="'agenda.can_be_speaker'" class="add-self-buttons">
|
||||
<div *ngIf="speakers">
|
||||
<button mat-raised-button (click)="addNewSpeaker()" *ngIf="!isOpInList()">
|
||||
<mat-icon>add</mat-icon>
|
||||
<span translate>Add me</span>
|
||||
</button>
|
||||
|
||||
<button mat-raised-button (click)="onDeleteButton()" *ngIf="isOpInList()">
|
||||
<mat-icon>remove</mat-icon>
|
||||
<span translate>Remove me</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card>
|
@ -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;
|
||||
}
|
||||
}
|
@ -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<SpeakerListComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [E2EImportsModule]
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(SpeakerListComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
@ -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<User[]>;
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
@ -0,0 +1,83 @@
|
||||
<os-head-bar
|
||||
[nav]="false"
|
||||
[goBack]="true"
|
||||
[editMode]="editTopic"
|
||||
(mainEvent)="setEditMode(!editTopic)"
|
||||
(saveEvent)="saveTopic()"
|
||||
>
|
||||
<!-- Title -->
|
||||
<div class="title-slot">
|
||||
<h2>
|
||||
<span *ngIf="newTopic && !editTopic" translate>New</span>
|
||||
<span *ngIf="editTopic && !newTopic" translate>Edit</span>
|
||||
<!-- Whitespace between "New" and "Topic" -->
|
||||
<span> </span> <span translate>Topic</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- Menu -->
|
||||
<div class="menu-slot">
|
||||
<button type="button" mat-icon-button [matMenuTriggerFor]="topicExtraMenu">
|
||||
<mat-icon>more_vert</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</os-head-bar>
|
||||
|
||||
<div *ngIf="topic" class="topic-container on-transition-fade">
|
||||
<div class="topic-title">
|
||||
<h2 *ngIf="!editTopic">{{ topic.title }}</h2>
|
||||
<h2 *ngIf="editTopic">{{ topicForm.get('title').value }}</h2>
|
||||
</div>
|
||||
|
||||
<mat-card *ngIf="topic || editTopic" class="topic-text">
|
||||
<div>
|
||||
<span *ngIf="!editTopic">
|
||||
{{ topic.text }}
|
||||
<div *ngIf="topic.text === ''" class="missing" translate>No description provided.</div>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div *ngIf="topic.attachments && topic.attachments.length > 0">
|
||||
<h3>
|
||||
<span translate>Attachments</span> <span>:</span>
|
||||
<!-- TODO: Mediafiles and attachments are not fully implemented -->
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<form *ngIf="editTopic" [formGroup]="topicForm" (ngSubmit)="saveTopic()" (keydown)="keyDownFunction($event)">
|
||||
<div>
|
||||
<mat-form-field>
|
||||
<input
|
||||
type="text"
|
||||
matInput
|
||||
osAutofocus
|
||||
required
|
||||
formControlName="title"
|
||||
placeholder="{{ 'Title' | translate}}"
|
||||
/>
|
||||
<mat-error *ngIf="topicForm.invalid" translate>A name is required</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<mat-form-field>
|
||||
<textarea matInput formControlName="text" placeholder="{{ 'Description ' | translate}}"></textarea>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<!-- TODO: Select Mediafiles as attachments here -->
|
||||
</form>
|
||||
</mat-card>
|
||||
</div>
|
||||
|
||||
<mat-menu #topicExtraMenu="matMenu">
|
||||
<button mat-menu-item *ngIf="topic" [routerLink]="getSpeakerLink()">
|
||||
<mat-icon>mic</mat-icon>
|
||||
<span translate>List of speakers</span>
|
||||
</button>
|
||||
<mat-divider></mat-divider>
|
||||
<button mat-menu-item class="red-warning-text" (click)="onDeleteButton()">
|
||||
<mat-icon>delete</mat-icon>
|
||||
<span translate>Delete</span>
|
||||
</button>
|
||||
</mat-menu>
|
@ -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;
|
||||
}
|
||||
}
|
@ -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<TopicDetailComponent>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
@ -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<void> {
|
||||
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<any> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
89
client/src/app/site/agenda/models/view-speaker.ts
Normal file
89
client/src/app/site/agenda/models/view-speaker.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
58
client/src/app/site/agenda/models/view-topic.ts
Normal file
58
client/src/app/site/agenda/models/view-topic.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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<ViewItem, Item> {
|
||||
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<BaseModel>(
|
||||
agendaItem.content_object.collection,
|
||||
agendaItem.content_object.id
|
||||
@ -45,6 +61,88 @@ export class AgendaRepositoryService extends BaseRepository<ViewItem, Item> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
const restUrl = `rest/agenda/item/${agenda.id}/manage_speaker/`;
|
||||
await this.httpService.post<Identifiable>(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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<ViewItem, Item> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
@ -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<ViewTopic, Topic> {
|
||||
/**
|
||||
* 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<Topic>): Promise<Identifiable> {
|
||||
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<Topic>, viewTopic: ViewTopic): Promise<void> {
|
||||
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<void> {
|
||||
return await this.dataSend.deleteModel(viewTopic.topic);
|
||||
}
|
||||
}
|
@ -39,20 +39,31 @@
|
||||
</div>
|
||||
|
||||
<mat-menu #motionExtraMenu="matMenu">
|
||||
<button mat-menu-item>
|
||||
<mat-icon>picture_as_pdf</mat-icon>
|
||||
<span translate>PDF</span>
|
||||
</button>
|
||||
<button mat-menu-item>
|
||||
<!-- possible icons: screen_share, cast, videocam -->
|
||||
<mat-icon>videocam</mat-icon>
|
||||
<span translate>Project</span>
|
||||
</button>
|
||||
<mat-divider></mat-divider>
|
||||
<button mat-menu-item class='red-warning-text' (click)='deleteMotionButton()'>
|
||||
<mat-icon>delete</mat-icon>
|
||||
<span translate>Delete</span>
|
||||
</button>
|
||||
<div *ngIf="motion">
|
||||
<button mat-menu-item>
|
||||
<mat-icon>picture_as_pdf</mat-icon>
|
||||
<span translate>PDF</span>
|
||||
</button>
|
||||
|
||||
<button mat-menu-item [routerLink]="getSpeakerLink()">
|
||||
<mat-icon>mic</mat-icon>
|
||||
<span translate>List of speakers</span>
|
||||
</button>
|
||||
|
||||
<button mat-menu-item>
|
||||
<!-- possible icons: screen_share, cast, videocam -->
|
||||
<mat-icon>videocam</mat-icon>
|
||||
<span translate>Project</span>
|
||||
</button>
|
||||
|
||||
<mat-divider></mat-divider>
|
||||
|
||||
<button mat-menu-item class='red-warning-text' (click)='deleteMotionButton()'>
|
||||
<mat-icon>delete</mat-icon>
|
||||
<span translate>Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</mat-menu>
|
||||
</os-head-bar>
|
||||
|
||||
|
@ -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<void> {
|
||||
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
|
||||
*/
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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({
|
||||
|
@ -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);
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user