Add controls controls to agenda list

Adds information about duration, visibility and comment to agenda list view
Allows channg these information over a dialog component on the
information column
This commit is contained in:
Sean Engelhardt 2018-12-12 18:06:06 +01:00 committed by Sean
parent 12ce434db5
commit a338884a62
21 changed files with 704 additions and 70 deletions

View File

@ -61,6 +61,17 @@ export class Item extends ProjectableBaseModel {
return this.speakers.filter(speaker => speaker.state === SpeakerState.WAITING).length;
}
/**
* Return the type as string
*/
public get verboseType(): string {
if (this.type) {
return itemVisibilityChoices.find(visibilityType => visibilityType.key === this.type).name;
} else {
return '';
}
}
public getTitle(): string {
return this.title;
}

View File

@ -5,12 +5,14 @@ import { AgendaRoutingModule } from './agenda-routing.module';
import { SharedModule } from '../../shared/shared.module';
import { AgendaListComponent } from './components/agenda-list/agenda-list.component';
import { TopicDetailComponent } from './components/topic-detail/topic-detail.component';
import { ItemInfoDialogComponent } from './components/item-info-dialog/item-info-dialog.component';
/**
* AppModule for the agenda and it's children.
*/
@NgModule({
imports: [CommonModule, AgendaRoutingModule, SharedModule],
declarations: [AgendaListComponent, TopicDetailComponent]
entryComponents: [ ItemInfoDialogComponent ],
declarations: [AgendaListComponent, TopicDetailComponent, ItemInfoDialogComponent]
})
export class AgendaModule {}

View File

@ -21,7 +21,7 @@
<!-- selector column -->
<ng-container matColumnDef="selector">
<mat-header-cell *matHeaderCellDef mat-sort-header class="checkbox-cell"></mat-header-cell>
<mat-cell *matCellDef="let item" class="checkbox-cell">
<mat-cell (click)="selectItem(item, $event)" *matCellDef="let item" class="checkbox-cell">
<mat-icon>{{ isSelected(item) ? 'check_circle' : '' }}</mat-icon>
</mat-cell>
</ng-container>
@ -29,13 +29,32 @@
<!-- title column -->
<ng-container matColumnDef="title">
<mat-header-cell *matHeaderCellDef mat-sort-header>Topic</mat-header-cell>
<mat-cell *matCellDef="let item">{{ item.getListTitle() }}</mat-cell>
<!-- <mat-cell (click)="onTitleColumn(item)" *matCellDef="let item"> -->
<mat-cell (click)="selectItem(item, $event)" *matCellDef="let item">
<span *ngIf="item.closed"> <mat-icon class="done-check">check</mat-icon> </span>
<span> {{ item.getListTitle() }} </span>
</mat-cell>
</ng-container>
<!-- Duration column -->
<ng-container matColumnDef="duration">
<mat-header-cell *matHeaderCellDef mat-sort-header>Duration</mat-header-cell>
<mat-cell *matCellDef="let item">{{ item.duration }}</mat-cell>
<!-- Info column -->
<ng-container matColumnDef="info">
<mat-header-cell *matHeaderCellDef mat-sort-header>Info</mat-header-cell>
<mat-cell (click)="openEditInfo(item)" *matCellDef="let item">
<div class="info-col-items">
<div *ngIf="item.verboseType">
<mat-icon>visibility</mat-icon>
{{ item.verboseType | translate }}
</div>
<div *ngIf="item.duration">
<mat-icon>access_time</mat-icon>
{{ durationService.durationToString(item.duration) }}
</div>
<div *ngIf="item.comment">
<mat-icon>comment</mat-icon>
{{ item.comment }}
</div>
</div>
</mat-cell>
</ng-container>
<!-- Speakers column -->
@ -50,10 +69,20 @@
</mat-cell>
</ng-container>
<!-- menu -->
<ng-container matColumnDef="menu">
<mat-header-cell *matHeaderCellDef mat-sort-header>Menu</mat-header-cell>
<mat-cell *matCellDef="let item">
<button mat-icon-button [matMenuTriggerFor]="singleItemMenu" [matMenuTriggerData]="{ item: item }">
<mat-icon>more_vert</mat-icon>
</button>
</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="getColumnDefinition()"></mat-header-row>
<mat-row
class="lg"
[ngClass]="selectedRows.indexOf(row) >= 0 ? 'selected' : ''"
(click)="selectItem(row, $event)"
*matRowDef="let row; columns: getColumnDefinition()"
></mat-row>
</mat-table>
@ -61,46 +90,75 @@
<mat-menu #agendaMenu="matMenu">
<div *ngIf="!isMultiSelect">
<button mat-menu-item *osPerms="'agenda.can_manage'" (click)="toggleMultiSelect()">
<mat-icon>library_add</mat-icon>
<span translate>Multiselect</span>
</button>
<div *osPerms="'agenda.can_manage'">
<!-- Enable multi select -->
<button mat-menu-item (click)="toggleMultiSelect()">
<mat-icon>library_add</mat-icon>
<span translate>Multiselect</span>
</button>
<!-- automatic numbering -->
<button mat-menu-item *ngIf="isNumberingAllowed" (click)="onAutoNumbering()">
<mat-icon>format_list_numbered</mat-icon>
<span translate>Numbering</span>
</button>
</div>
</div>
<div *ngIf="isMultiSelect">
<!-- Exit multi select -->
<button mat-menu-item (click)="toggleMultiSelect()">
<mat-icon>library_add</mat-icon>
<span translate>Exit multiselect</span>
</button>
<!-- Select all -->
<button mat-menu-item (click)="selectAll()">
<mat-icon>done_all</mat-icon>
<span translate>Select all</span>
</button>
<!-- Deselect all -->
<button mat-menu-item (click)="deselectAll()">
<mat-icon>clear</mat-icon>
<span translate>Deselect all</span>
</button>
<mat-divider></mat-divider>
<div *osPerms="'agenda.can_manage'">
<!-- Close selected -->
<button mat-menu-item (click)="setClosedSelected(true)">
<mat-icon>done</mat-icon>
<span translate>Close</span>
</button>
<!-- Open selected -->
<button mat-menu-item (click)="setClosedSelected(false)">
<mat-icon>redo</mat-icon>
<span translate>Open</span>
</button>
<mat-divider></mat-divider>
<button mat-menu-item (click)="setVisibilitySelected(true)">
<mat-icon>visibility</mat-icon>
<span translate>Set visible</span>
<!-- Set multiple to public -->
<button mat-menu-item (click)="setAgendaType(1)">
<mat-icon>public</mat-icon>
<span translate>Set public</span>
</button>
<button mat-menu-item (click)="setVisibilitySelected(false)">
<!-- Set multiple to internal -->
<button mat-menu-item (click)="setAgendaType(2)">
<mat-icon>visibility</mat-icon>
<span translate>Set internal</span>
</button>
<!-- Set multiple to hidden -->
<button mat-menu-item (click)="setAgendaType(3)">
<mat-icon>visibility_off</mat-icon>
<span translate>Set invisible</span>
<span translate>Set hidden</span>
</button>
<mat-divider></mat-divider>
<!-- Delete selected -->
<button mat-menu-item class="red-warning-text" (click)="deleteSelected()">
<mat-icon>delete</mat-icon>
<span translate>Delete</span>
@ -108,3 +166,33 @@
</div>
</div>
</mat-menu>
<mat-menu #singleItemMenu="matMenu">
<ng-template matMenuContent let-item="item">
<!-- Done check -->
<button mat-menu-item (click)="onDoneSingleButton(item)">
<mat-icon color="accent"> {{ item.closed ? 'check_box' : 'check_box_outline_blank' }} </mat-icon>
<span translate>Done</span>
</button>
<!-- List of speakers for mobile -->
<button mat-menu-item (click)="onSpeakerIcon(item)" *ngIf="vp.isMobile">
<mat-icon [matBadge]="item.speakerAmount > 0 ? item.speakerAmount : null" matBadgeColor="accent">
mic
</mat-icon>
<span translate>List of speakers</span>
</button>
<!-- Edit button -->
<button mat-menu-item (click)="openEditInfo(item)">
<mat-icon>edit</mat-icon>
<span translate>Edit</span>
</button>
<!-- Delete Button -->
<button mat-menu-item class="red-warning-text" (click)="onDelete(item)">
<mat-icon>delete</mat-icon>
<span translate>Delete</span>
</button>
</ng-template>
</mat-menu>

View File

@ -3,17 +3,42 @@
/** Title */
.mat-column-title {
padding-left: 26px;
flex: 1 0 200px;
flex: 2 0 0;
.done-check {
margin-right: 10px;
}
}
/** Duration */
.mat-column-duration {
flex: 0 0 100px;
.mat-column-info {
flex: 2 0 0;
.info-col-items {
display: inline-block;
white-space: nowrap;
font-size: 14px;
.mat-icon {
display: inline-flex;
vertical-align: middle;
$icon-size: 18px;
font-size: $icon-size;
height: $icon-size;
width: $icon-size;
}
}
}
/** Speakers indicator */
.mat-column-speakers {
flex: 0 0 100px;
flex: 0 0 50px;
}
/** menu indicator */
.mat-column-menu {
flex: 0 0 50px;
justify-content: flex-end !important;
}
}

View File

@ -1,13 +1,17 @@
import { Component, OnInit } from '@angular/core';
import { Router, ActivatedRoute } from '@angular/router';
import { Title } from '@angular/platform-browser';
import { MatSnackBar } from '@angular/material';
import { MatSnackBar, MatDialog } 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';
import { PromptService } from '../../../../core/services/prompt.service';
import { ItemInfoDialogComponent } from '../item-info-dialog/item-info-dialog.component';
import { ViewportService } from 'app/core/services/viewport.service';
import { DurationService } from 'app/site/core/services/duration.service';
import { ConfigService } from 'app/core/services/config.service';
/**
* List view for the agenda.
@ -18,6 +22,18 @@ import { PromptService } from '../../../../core/services/prompt.service';
styleUrls: ['./agenda-list.component.scss']
})
export class AgendaListComponent extends ListViewBaseComponent<ViewItem> implements OnInit {
/**
* Determine the display columns in desktop view
*/
public displayedColumnsDesktop: string[] = ['title', 'info', 'speakers', 'menu'];
/**
* Determine the display columns in mobile view
*/
public displayedColumnsMobile: string[] = ['title', 'menu'];
public isNumberingAllowed: boolean;
/**
* The usual constructor for components
* @param titleService Setting the browser tab title
@ -26,8 +42,11 @@ export class AgendaListComponent extends ListViewBaseComponent<ViewItem> impleme
* @param route Angulars ActivatedRoute
* @param router Angulars router
* @param repo the agenda repository,
* promptService:
*
* @param promptService the delete prompt
* @param dialog to change info values
* @param config read out config values
* @param vp determine the viewport
* @param durationService Converts numbers to readable duration strings
*/
public constructor(
titleService: Title,
@ -36,7 +55,11 @@ export class AgendaListComponent extends ListViewBaseComponent<ViewItem> impleme
private route: ActivatedRoute,
private router: Router,
private repo: AgendaRepositoryService,
private promptService: PromptService
private promptService: PromptService,
private dialog: MatDialog,
private config: ConfigService,
public vp: ViewportService,
public durationService: DurationService
) {
super(titleService, translate, matSnackBar);
@ -55,12 +78,17 @@ export class AgendaListComponent extends ListViewBaseComponent<ViewItem> impleme
this.dataSource.data = newAgendaItem;
this.checkSelection();
});
this.config
.get('agenda_enable_numbering')
.subscribe(autoNumbering => (this.isNumberingAllowed = autoNumbering));
}
/**
* Handler for click events on an agenda item row. Links to the content object
* Links to the content object.
* 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 singleSelectAction(item: ViewItem): void {
@ -68,8 +96,48 @@ export class AgendaListComponent extends ListViewBaseComponent<ViewItem> impleme
this.router.navigate([contentObject.getDetailStateURL()]);
}
/**
* Opens the item-info-dialog.
* Enable direct changing of various information
*
* @param item The view item that was clicked
*/
public openEditInfo(item: ViewItem): void {
const dialogRef = this.dialog.open(ItemInfoDialogComponent, {
width: '400px',
data: item
});
dialogRef.afterClosed().subscribe(result => {
if (result) {
if (result.durationText) {
result.duration = this.durationService.stringToDuration(result.durationText);
}
this.repo.update(result, item);
}
});
}
/**
* Click handler for the numbering button to enable auto numbering
*/
public async onAutoNumbering(): Promise<void> {
const content = this.translate.instant('Are you sure you want to number all agenda items?');
if (await this.promptService.open('', content)) {
await this.repo.autoNumbering().then(null, this.raiseError);
}
}
/**
* Click handler for the done button in the dot-menu
*/
public async onDoneSingleButton(item: ViewItem): Promise<void> {
await this.repo.update({ closed: !item.closed }, item).then(null, this.raiseError);
}
/**
* Handler for the speakers button
*
* @param item indicates the row that was clicked on
*/
public onSpeakerIcon(item: ViewItem): void {
@ -84,6 +152,18 @@ export class AgendaListComponent extends ListViewBaseComponent<ViewItem> impleme
this.router.navigate(['topics/new'], { relativeTo: this.route });
}
/**
* Delete handler for a single item
*
* @param item The item to delete
*/
public async onDelete(item: ViewItem): Promise<void> {
const content = this.translate.instant('Delete') + ` ${item.getTitle()}?`;
if (await this.promptService.open('Are you sure?', content)) {
await this.repo.delete(item).then(null, this.raiseError);
}
}
/**
* Handler for deleting multiple entries. Needs items in selectedRows, which
* is only filled with any data in multiSelect mode
@ -110,19 +190,24 @@ export class AgendaListComponent extends ListViewBaseComponent<ViewItem> impleme
}
/**
* Sets multiple entries' visibility. Needs items in selectedRows, which
* Sets multiple entries' agenda type. Needs items in selectedRows, which
* is only filled with any data in multiSelect mode.
*
* @param visible true if the item is to be shown
*/
public async setVisibilitySelected(visible: boolean): Promise<void> {
public async setAgendaType(agendaType: number): Promise<void> {
for (const agenda of this.selectedRows) {
await this.repo.update({ is_hidden: visible }, agenda);
await this.repo.update({ type: agendaType }, agenda).then(null, this.raiseError);
}
}
/**
* Determine what columns to show
*
* @returns an array of strings with the dialogs to show
*/
public getColumnDefinition(): string[] {
const list = ['title', 'duration', 'speakers'];
const list = this.vp.isMobile ? this.displayedColumnsMobile : this.displayedColumnsDesktop;
if (this.isMultiSelect) {
return ['selector'].concat(list);
}

View File

@ -0,0 +1,39 @@
<h1 mat-dialog-title>{{ 'Change values for' | translate }} {{ item.getTitle() }}</h1>
<div mat-dialog-content>
<form class="itemDialogForm" [formGroup]="agendaInfoForm" (keydown)="onKeyDown($event)" (ngSubmit)="saveItemInfo()">
<!-- Visibility -->
<mat-form-field>
<mat-select formControlName="type" placeholder="{{ 'Agenda visibility' | translate }}">
<mat-option *ngFor="let type of itemVisibility" [value]="type.key">
<span>{{ type.name | translate }}</span>
</mat-option>
</mat-select>
</mat-form-field>
<!-- Duration -->
<mat-form-field>
<input type="string" matInput placeholder="{{ 'Duration' | translate }}" formControlName="durationText" />
</mat-form-field>
<!-- Item number (prefix) -->
<mat-form-field>
<input matInput placeholder="{{ 'Item number' | translate }}" formControlName="item_number" />
</mat-form-field>
<!-- Comment -->
<mat-form-field>
<textarea
matInput
formControlName="comment"
placeholder="{{ 'Comment' | translate }}"
cdkTextareaAutosize
cdkAutosizeMinRows="3"
cdkAutosizeMaxRows="5"
></textarea>
</mat-form-field>
</form>
</div>
<div mat-dialog-actions>
<button mat-button (click)="saveItemInfo()"><span translate>Save</span></button>
<button mat-button (click)="onCancelButton()"><span translate>Cancel</span></button>
</div>

View File

@ -0,0 +1,8 @@
.itemDialogForm {
display: inline-block;
::ng-deep {
.mat-form-field {
width: 100%;
}
}
}

View File

@ -0,0 +1,26 @@
import { async, TestBed } from '@angular/core/testing';
// import { ItemInfoDialogComponent } from './item-info-dialog.component';
import { E2EImportsModule } from 'e2e-imports.module';
describe('ItemInfoDialogComponent', () => {
// let component: ItemInfoDialogComponent;
// let fixture: ComponentFixture<ItemInfoDialogComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule]
}).compileComponents();
}));
// TODO: You cannot create this component in the standard way. Needs different testing.
beforeEach(() => {
/*fixture = TestBed.createComponent(ItemInfoDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();*/
});
/*it('should create', () => {
expect(component).toBeTruthy();
});*/
});

View File

@ -0,0 +1,81 @@
import { Component, Inject } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material';
import { ViewItem } from '../../models/view-item';
import { itemVisibilityChoices } from 'app/shared/models/agenda/item';
import { DurationService } from 'app/site/core/services/duration.service';
/**
* Dialog component to change agenda item details
*/
@Component({
selector: 'os-item-info-dialog',
templateUrl: './item-info-dialog.component.html',
styleUrls: ['./item-info-dialog.component.scss']
})
export class ItemInfoDialogComponent {
/**
* Holds the agenda item form
*/
public agendaInfoForm: FormGroup;
/**
* Hold item visibility
*/
public itemVisibility = itemVisibilityChoices;
/**
* Constructor
*
* @param formBuilder construct the form
* @param durationService Converts numbers to readable duration strings
* @param dialogRef the dialog reference
* @param item the item that was selected
*/
public constructor(
public formBuilder: FormBuilder,
public durationService: DurationService,
public dialogRef: MatDialogRef<ItemInfoDialogComponent>,
@Inject(MAT_DIALOG_DATA)
public item: ViewItem
) {
this.agendaInfoForm = this.formBuilder.group({
type: [''],
durationText: [''],
item_number: [''],
comment: ['']
});
// load current values
this.agendaInfoForm.get('type').setValue(item.type);
this.agendaInfoForm.get('durationText').setValue(this.durationService.durationToString(item.duration));
this.agendaInfoForm.get('item_number').setValue(item.itemNumber);
this.agendaInfoForm.get('comment').setValue(item.comment);
}
/**
* Function to save the item
*/
public saveItemInfo(): void {
this.dialogRef.close(this.agendaInfoForm.value);
}
/**
* Click on cancel button
*/
public onCancelButton(): void {
this.dialogRef.close();
}
/**
* clicking Shift and Enter will save the form
*
* @param event the key that was clicked
*/
public onKeyDown(event: KeyboardEvent): void {
if (event.key === 'Enter' && event.shiftKey) {
this.saveItemInfo();
}
}
}

View File

@ -1,4 +1,6 @@
<os-head-bar
[mainButton]="isAllowed('edit')"
mainButtonIcon="edit"
[nav]="false"
[goBack]="true"
[editMode]="editTopic"
@ -29,16 +31,22 @@
<h2 *ngIf="editTopic">{{ topicForm.get('title').value }}</h2>
</div>
<mat-card *ngIf="topic || editTopic" class="topic-text">
<mat-card *ngIf="topic.text || topic.hasAttachments() || editTopic" class="topic-text">
<div>
<span *ngIf="!editTopic">
<!-- Render topic text as HTML -->
<div [innerHTML]="topic.text"></div>
</span>
</div>
<div *ngIf="topic.attachments && topic.attachments.length > 0">
<div *ngIf="topic.hasAttachments() && !editTopic">
<h3>
<span translate>Attachments</span>:
<mat-list dense>
<mat-list-item *ngFor="let file of topic.attachments">
<a [routerLink]="file.getDownloadUrl()" target="_blank">{{ file.title }}</a>
</mat-list-item>
</mat-list>
<!-- TODO: Mediafiles and attachments are not fully implemented -->
</h3>
</div>
@ -52,14 +60,49 @@
osAutofocus
required
formControlName="title"
placeholder="{{ 'Title' | translate}}"
placeholder="{{ 'Title' | translate }}"
/>
<mat-error *ngIf="topicForm.invalid" translate>A name is required</mat-error>
</mat-form-field>
</div>
<!-- The editor -->
<div><editor formControlName="text" [init]="tinyMceSettings"></editor></div>
<!-- TODO: Select Mediafiles as attachments here -->
<editor formControlName="text" [init]="tinyMceSettings"></editor>
<!-- Attachments -->
<os-search-value-selector
ngDefaultControl
[form]="topicForm"
[formControl]="topicForm.get('attachments_id')"
[multiple]="true"
listname="{{ 'Attachments' | translate }}"
[InputListValues]="mediafilesObserver"
></os-search-value-selector>
<div *ngIf="newTopic">
<!-- Visibility -->
<div>
<mat-form-field>
<mat-select formControlName="agenda_type" placeholder="{{ 'Agenda visibility' | translate }}">
<mat-option *ngFor="let type of itemVisibility" [value]="type.key">
<span>{{ type.name | translate }}</span>
</mat-option>
</mat-select>
</mat-form-field>
</div>
<!-- Parent item -->
<div>
<os-search-value-selector
ngDefaultControl
[form]="topicForm"
[formControl]="topicForm.get('agenda_parent_id')"
[multiple]="false"
[includeNone]="true"
listname="{{ 'Parent Item' | translate }}"
[InputListValues]="agendaItemObserver"
></os-search-value-selector>
</div>
</div>
</form>
</mat-card>
</div>

View File

@ -11,6 +11,11 @@ import { BaseViewComponent } from 'app/site/base/base-view';
import { PromptService } from 'app/core/services/prompt.service';
import { TopicRepositoryService } from '../../services/topic-repository.service';
import { ViewTopic } from '../../models/view-topic';
import { OperatorService } from 'app/core/services/operator.service';
import { BehaviorSubject } from 'rxjs';
import { DataStoreService } from 'app/core/services/data-store.service';
import { Mediafile } from 'app/shared/models/mediafiles/mediafile';
import { Item, itemVisibilityChoices } from 'app/shared/models/agenda/item';
/**
* Detail page for topics.
@ -41,8 +46,24 @@ export class TopicDetailComponent extends BaseViewComponent {
*/
public topicForm: FormGroup;
/**
* Subject for mediafiles
*/
public mediafilesObserver: BehaviorSubject<Mediafile[]>;
/**
* Subject for agenda items
*/
public agendaItemObserver: BehaviorSubject<Item[]>;
/**
* Determine visibility states for the agenda that will be created implicitly
*/
public itemVisibility = itemVisibilityChoices;
/**
* Constructor for the topic detail page.
*
* @param title Setting the browsers title
* @param matSnackBar display errors and other messages
* @param translate Handles translations
@ -52,6 +73,8 @@ export class TopicDetailComponent extends BaseViewComponent {
* @param formBuilder Angulars FormBuilder
* @param repo The topic repository
* @param promptService Allows warning before deletion attempts
* @param operator The current user
* @param DS Data Store
*/
public constructor(
title: Title,
@ -62,15 +85,29 @@ export class TopicDetailComponent extends BaseViewComponent {
private location: Location,
private formBuilder: FormBuilder,
private repo: TopicRepositoryService,
private promptService: PromptService
private promptService: PromptService,
private operator: OperatorService,
private DS: DataStoreService
) {
super(title, translate, matSnackBar);
this.getTopicByUrl();
this.createForm();
this.mediafilesObserver = new BehaviorSubject(this.DS.getAll(Mediafile));
this.agendaItemObserver = new BehaviorSubject(this.DS.getAll(Item));
this.DS.changeObservable.subscribe(newModel => {
if (newModel instanceof Item) {
this.agendaItemObserver.next(DS.getAll(Item));
} else if (newModel instanceof Mediafile) {
this.mediafilesObserver.next(DS.getAll(Mediafile));
}
});
}
/**
* Set the edit mode to the given Status
*
* @param mode
*/
public setEditMode(mode: boolean): void {
@ -88,13 +125,17 @@ export class TopicDetailComponent extends BaseViewComponent {
*/
public async saveTopic(): Promise<void> {
if (this.newTopic && this.topicForm.valid) {
if (!this.topicForm.value.agenda_parent_id) {
delete this.topicForm.value.agenda_parent_id;
}
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);
await this.repo.update(this.topicForm.value, this.topic);
}
}
@ -103,9 +144,14 @@ export class TopicDetailComponent extends BaseViewComponent {
*/
public createForm(): void {
this.topicForm = this.formBuilder.group({
title: ['', Validators.required],
text: ['']
agenda_type: [],
agenda_parent_id: [],
attachments_id: [[]],
text: [''],
title: ['', Validators.required]
});
this.topicForm.get('agenda_type').setValue(1);
}
/**
@ -116,6 +162,7 @@ export class TopicDetailComponent extends BaseViewComponent {
Object.keys(this.topicForm.controls).forEach(ctrl => {
topicPatch[ctrl] = this.topic[ctrl];
});
this.topicForm.patchValue(topicPatch);
}
@ -139,6 +186,7 @@ export class TopicDetailComponent extends BaseViewComponent {
/**
* Loads a top from the repository
*
* @param id the id of the required topic
*/
public loadTopic(id: number): void {
@ -172,14 +220,31 @@ export class TopicDetailComponent extends BaseViewComponent {
/**
* Handler for the delete button. Uses the PromptService
*/
public async onDeleteButton(): Promise<any> {
public async onDeleteButton(): Promise<void> {
const content = this.translate.instant('Delete') + ` ${this.topic.title}?`;
if (await this.promptService.open('Are you sure?', content)) {
await this.repo.delete(this.topic);
await this.repo.delete(this.topic).then(null, this.raiseError);
this.router.navigate(['/agenda']);
}
}
/**
* Checks if the operator is allowed to perform one of the given actions
*
* @param action the desired action
* @returns true if the operator has the correct permissions, false of not
*/
public isAllowed(action: string): boolean {
switch (action) {
case 'see':
return this.operator.hasPerms('agenda.can_manage');
case 'edit':
return this.operator.hasPerms('agenda.can_see');
case 'default':
return false;
}
}
/**
* clicking Shift and Enter will save automatically
* Hitting escape while in topicForm should cancel editing

View File

@ -18,6 +18,10 @@ export class ViewItem extends BaseViewModel {
return this.item ? this.item.id : null;
}
public get itemNumber(): string {
return this.item ? this.item.item_number : null;
}
public get duration(): number {
return this.item ? this.item.duration : null;
}
@ -26,6 +30,22 @@ export class ViewItem extends BaseViewModel {
return this.item ? this.item.speakerAmount : null;
}
public get type(): number {
return this.item ? this.item.type : null;
}
public get verboseType(): string {
return this.item.verboseType;
}
public get comment(): string {
return this.item ? this.item.comment : null;
}
public get closed(): boolean {
return this.item ? this.item.closed : null;
}
public constructor(item: Item, contentObject: AgendaBaseModel) {
super();
this._item = item;
@ -40,12 +60,20 @@ export class ViewItem extends BaseViewModel {
}
}
/**
* Create the list view title.
* If a number was given, 'whitespac-dot-whitespace' will be added to the prefix number
*
* @returns the agenda list title as string
*/
public getListTitle(): string {
const contentObject: AgendaBaseModel = this.contentObject;
const numberPrefix = this.itemNumber ? `${this.itemNumber} · ` : '';
if (contentObject) {
return contentObject.getAgendaTitleWithType();
return numberPrefix + contentObject.getAgendaTitleWithType();
} else {
return this.item ? this.item.title_with_type : null;
return this.item ? numberPrefix + this.item.title_with_type : null;
}
}

View File

@ -2,9 +2,11 @@ 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';
import { BaseModel } from 'app/shared/models/base/base-model';
/**
* Provides "safe" access to topic with all it's components
* @ignore
*/
export class ViewTopic extends BaseViewModel {
private _topic: Topic;
@ -31,6 +33,10 @@ export class ViewTopic extends BaseViewModel {
return this.topic ? this.topic.agenda_item_id : null;
}
public get attachments_id(): number[] {
return this.topic ? this.topic.attachments_id : null;
}
public get title(): string {
return this.topic ? this.topic.title : null;
}
@ -50,9 +56,16 @@ export class ViewTopic extends BaseViewModel {
return this.title;
}
public updateValues(update: Topic): void {
if (this.id === update.id) {
this._topic = update;
public hasAttachments(): boolean {
return this.attachments && this.attachments.length > 0;
}
public updateValues(update: BaseModel): void {
if (update instanceof Mediafile) {
if (this.topic && this.attachments_id && this.attachments_id.includes(update.id)) {
const attachmentIndex = this.attachments.findIndex(mediafile => mediafile.id === update.id);
this.attachments[attachmentIndex] = update as Mediafile;
}
}
}
}

View File

@ -15,6 +15,7 @@ import { Speaker } from 'app/shared/models/agenda/speaker';
import { User } from 'app/shared/models/users/user';
import { HttpService } from 'app/core/services/http.service';
import { ConfigService } from 'app/core/services/config.service';
import { DataSendService } from 'app/core/services/data-send.service';
/**
* Repository service for users
@ -27,16 +28,19 @@ import { ConfigService } from 'app/core/services/config.service';
export class AgendaRepositoryService extends BaseRepository<ViewItem, Item> {
/**
* Contructor for agenda repository.
*
* @param DS The DataStore
* @param httpService OpenSlides own HttpService
* @param mapperService OpenSlides mapping service for collection strings
* @param config Read config variables
* @param dataSend send models to the server
*/
public constructor(
protected DS: DataStoreService,
private httpService: HttpService,
mapperService: CollectionStringModelMapperService,
private config: ConfigService
private config: ConfigService,
private dataSend: DataSendService
) {
super(DS, mapperService, Item);
}
@ -44,6 +48,7 @@ export class AgendaRepositoryService extends BaseRepository<ViewItem, Item> {
/**
* Returns the corresponding content object to a given {@link Item} as an {@link AgendaBaseModel}
* 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.
*/
@ -68,6 +73,7 @@ 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
*/
@ -88,8 +94,8 @@ export class AgendaRepositoryService extends BaseRepository<ViewItem, Item> {
/**
* 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
*/
@ -100,6 +106,7 @@ export class AgendaRepositoryService extends BaseRepository<ViewItem, Item> {
/**
* Sets the given speaker ID to Speak
*
* @param id the speakers id
* @param agenda the target agenda item
*/
@ -110,6 +117,7 @@ export class AgendaRepositoryService extends BaseRepository<ViewItem, Item> {
/**
* Stops the current speaker
*
* @param agenda the target agenda item
*/
public async stopSpeaker(agenda: Item): Promise<void> {
@ -119,6 +127,7 @@ export class AgendaRepositoryService extends BaseRepository<ViewItem, Item> {
/**
* 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
@ -130,6 +139,7 @@ export class AgendaRepositoryService extends BaseRepository<ViewItem, Item> {
/**
* Deletes the given speaker for the agenda
*
* @param id the speakers id
* @param agenda the target agenda item
*/
@ -140,6 +150,7 @@ export class AgendaRepositoryService extends BaseRepository<ViewItem, Item> {
/**
* Posts an (manually) sorted speaker list to the server
*
* @param ids array of speaker id numbers
* @param Item the target agenda item
*/
@ -149,34 +160,48 @@ export class AgendaRepositoryService extends BaseRepository<ViewItem, Item> {
}
/**
* @ignore
* Updates an agenda item
*
* TODO: used over not-yet-existing detail view
* @param update contains the update data
* @param viewItem the item to update
*/
public async update(item: Partial<Item>, viewUser: ViewItem): Promise<void> {
return null;
public async update(update: Partial<Item>, viewItem: ViewItem): Promise<void> {
const updateItem = viewItem.item;
updateItem.patchValues(update);
return await this.dataSend.partialUpdateModel(updateItem);
}
/**
* Trigger the automatic numbering sequence on the server
*/
public async autoNumbering(): Promise<void> {
await this.httpService.post('/rest/agenda/item/numbering/');
}
/**
* @ignore
*
* TODO: used over not-yet-existing detail view
* TODO: Usually, agenda items are deleted with their corresponding content object
* However, deleting an agenda item might be interpretet with "removing an item
* from the agenda" permanently. Usually, items might juse be hidden but not
* deleted (right now)
*/
public async delete(item: ViewItem): Promise<void> {
return null;
public delete(item: ViewItem): Promise<void> {
throw new Error("Method not implemented.");
}
/**
* @ignore
*
* TODO: used over not-yet-existing detail view
* Agenda items are created implicitly and do not have on create functions
*/
public async create(item: Item): Promise<Identifiable> {
return null;
throw new Error("Method not implemented.");
}
/**
* Creates the viewItem out of a given item
*
* @param item the item that should be converted to view item
* @returns a new view item
*/

View File

@ -34,6 +34,7 @@ export class TopicRepositoryService extends BaseRepository<ViewTopic, Topic> {
/**
* 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
*/
@ -56,6 +57,7 @@ export class TopicRepositoryService extends BaseRepository<ViewTopic, Topic> {
/**
* Save a new topic
*
* @param topicData Partial topic data to be created
* @returns an Identifiable (usually id) as promise
*/
@ -81,6 +83,7 @@ export class TopicRepositoryService extends BaseRepository<ViewTopic, Topic> {
/**
* Delete a topic
*
* @param viewTopic the topic that should be removed
*/
public async delete(viewTopic: ViewTopic): Promise<void> {

View File

@ -7,7 +7,7 @@ import { BaseViewComponent } from './base-view';
export abstract class ListViewBaseComponent<V extends BaseViewModel> extends BaseViewComponent {
/**
* The data source for a table. Requires to be initialised with a BaseViewModel
* The data source for a table. Requires to be initialized with a BaseViewModel
*/
public dataSource: MatTableDataSource<V>;
@ -17,12 +17,12 @@ export abstract class ListViewBaseComponent<V extends BaseViewModel> extends Bas
protected canMultiSelect = false;
/**
* Current state of the multiSelect mode. TODO Could be merged with edit mode?
* Current state of the multi select mode. TODO Could be merged with edit mode?
*/
private _multiSelectModus = false;
private _multiSelectMode = false;
/**
* An array of currently selected items, upon which multiselect actions can be performed
* An array of currently selected items, upon which multi select actions can be performed
* see {@link selectItem}.
*/
public selectedRows: V[];
@ -75,7 +75,7 @@ export abstract class ListViewBaseComponent<V extends BaseViewModel> extends Bas
*/
public selectItem(row: V, event: MouseEvent): void {
event.stopPropagation();
if (!this._multiSelectModus) {
if (!this._multiSelectMode) {
this.singleSelectAction(row);
} else {
const idx = this.selectedRows.indexOf(row);
@ -99,10 +99,10 @@ export abstract class ListViewBaseComponent<V extends BaseViewModel> extends Bas
*/
public toggleMultiSelect(): void {
if (!this.canMultiSelect || this.isMultiSelect) {
this._multiSelectModus = false;
this._multiSelectMode = false;
this.clearSelection();
} else {
this._multiSelectModus = true;
this._multiSelectMode = true;
}
}
@ -121,7 +121,7 @@ export abstract class ListViewBaseComponent<V extends BaseViewModel> extends Bas
* Returns the current state of the multiSelect modus
*/
public get isMultiSelect(): boolean {
return this._multiSelectModus;
return this._multiSelectMode;
}
/**
@ -129,7 +129,7 @@ export abstract class ListViewBaseComponent<V extends BaseViewModel> extends Bas
* @param item The row's entry
*/
public isSelected(item: V): boolean {
if (!this._multiSelectModus) {
if (!this._multiSelectMode) {
return false;
}
return this.selectedRows.indexOf(item) >= 0;

View File

@ -0,0 +1,15 @@
import { TestBed } from '@angular/core/testing';
import { DurationService } from './duration.service';
import { E2EImportsModule } from 'e2e-imports.module';
describe('DurationService', () => {
beforeEach(() => TestBed.configureTestingModule({
imports: [E2EImportsModule]
}));
it('should be created', () => {
const service: DurationService = TestBed.get(DurationService);
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,72 @@
import { Injectable } from '@angular/core';
/**
* Helper service to convert numbers to time representation
* and vice versa
*
* @example
* ```ts
* // will result in 70
* const a = this.durationService.stringToDuration('01:10h');
*
* // will also result in 70
* const b = this.durationService.stringToDuration('01:10');
*
* // will result in 0
* const c = this.durationService.stringToDuration('01:10b');
* ```
*
* @example
* ```ts
* // will result in 01:10 h
* const a = this.durationService.durationToString(70);
* ```
*/
@Injectable({
providedIn: 'root'
})
export class DurationService {
/**
* Constructor
*/
public constructor() {}
/**
* Transform a duration string to duration in minutes.
*
* @param durationText the text to be transformed into a duration
* @returns time in minutes or 0 if values are below 0 or no parsable numbers
*/
public stringToDuration(durationText: string): number {
const splitDuration = durationText.replace('h', '').split(':');
let time: number;
if (splitDuration.length > 1 && !isNaN(+splitDuration[0]) && !isNaN(+splitDuration[1])) {
time = +splitDuration[0] * 60 + +splitDuration[1];
} else if (splitDuration.length === 1 && !isNaN(+splitDuration[0])) {
time = +splitDuration[0];
}
if (!time || time < 0) {
time = 0;
}
return time;
}
/**
* Converts a duration number (given in minutes)
* To a string in HH:MM format
*
* @param duration value in minutes
* @returns a more human readable time representation
*/
public durationToString(duration: number): string {
const hours = Math.floor(duration / 60);
const minutes = `0${Math.floor(duration - hours * 60)}`.slice(-2);
if (!isNaN(+hours) && !isNaN(+minutes)) {
return `${hours}:${minutes} h`;
} else {
return '';
}
}
}

View File

@ -309,7 +309,6 @@
<form
class="motion-content"
[formGroup]="contentForm"
(clickdown)="onKeyDown($event)"
(keydown)="onKeyDown($event)"
(ngSubmit)="saveMotion()"
*ngIf="motion"
@ -492,7 +491,7 @@
<os-search-value-selector
ngDefaultControl
[form]="contentForm"
[formControl]="contentForm.get('parent_id')"
[formControl]="contentForm.get('agenda_parent_id')"
[multiple]="false"
[includeNone]="true"
listname="{{ 'Parent Item' | translate }}"

View File

@ -449,7 +449,7 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit {
reason: [''],
category_id: [''],
attachments_id: [[]],
parent_id: [],
agenda_parent_id: [],
agenda_type: [''],
submitters_id: [],
supporters_id: [[]],
@ -495,6 +495,7 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit {
);
motionValues.text = '';
}
motion.deserialize(motionValues);
return motion;
}
@ -504,6 +505,11 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit {
*/
public async createMotion(): Promise<void> {
const newMotionValues = { ...this.contentForm.value };
if (!newMotionValues.agenda_parent_id) {
delete newMotionValues.agenda_parent_id;
}
const motion = this.prepareMotionForSave(newMotionValues, CreateMotion);
try {

View File

@ -1,5 +1,5 @@
<os-head-bar
[mainButton]="isAllowed('manage')"
[mainButton]="isAllowed('changePersonal')"
mainButtonIcon="edit"
[nav]="false"
[editMode]="editUser"