Merge pull request #3961 from tsiegleauq/create_angeda_items

Create agenda topics
This commit is contained in:
Jochen Saalfeld 2018-11-08 15:05:40 +01:00 committed by GitHub
commit 23b0965ff2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 1303 additions and 103 deletions

View File

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

View File

@ -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 }}.&nbsp;</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>

View File

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

View File

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

View File

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

View File

@ -25,6 +25,6 @@ export class Topic extends AgendaBaseModel {
}
public getDetailStateURL(): string {
return 'TODO';
return `/agenda/topics/${this.id}`;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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>.&nbsp;{{ speaker }}</span>
</div>
<div class="finished-suffix">
<!-- TODO: No Date or time class yet -->
<!-- <span> calc time here &nbsp;</span> -->
<!-- <span translate>minutes</span> -->
<span>&nbsp;(</span> <span translate>Start time</span> <span>:&nbsp;{{ 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>

View File

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

View File

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

View File

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

View File

@ -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>&nbsp;</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>

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
*/

View File

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

View File

@ -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({

View File

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