Merge pull request #4040 from MaximilianKrambach/os3/multiSelect2

Multiselect for advanced options
This commit is contained in:
Sean 2018-11-30 11:04:54 +01:00 committed by GitHub
commit d4d4ac838f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 1186 additions and 375 deletions

View File

@ -13,6 +13,7 @@ import { DataSendService } from './services/data-send.service';
import { ViewportService } from './services/viewport.service';
import { PromptDialogComponent } from '../shared/components/prompt-dialog/prompt-dialog.component';
import { HttpService } from './services/http.service';
import { ChoiceDialogComponent } from '../shared/components/choice-dialog/choice-dialog.component';
/** Global Core Module. Contains all global (singleton) services
*
@ -31,7 +32,7 @@ import { HttpService } from './services/http.service';
ViewportService,
WebsocketService
],
entryComponents: [PromptDialogComponent]
entryComponents: [PromptDialogComponent, ChoiceDialogComponent]
})
export class CoreModule {
/** make sure CoreModule is imported only by one NgModule, the AppModule */

View File

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

View File

@ -0,0 +1,44 @@
import { Injectable } from '@angular/core';
import { OpenSlidesComponent } from '../../openslides.component';
import { MatDialog } from '@angular/material';
import {
ChoiceDialogComponent,
ChoiceDialogOptions,
ChoiceAnswer
} from '../../shared/components/choice-dialog/choice-dialog.component';
/**
* A service for prompting the user to select a choice.
*/
@Injectable({
providedIn: 'root'
})
export class ChoiceService extends OpenSlidesComponent {
/**
* Ctor.
*
* @param dialog For opening the ChoiceDialog
*/
public constructor(private dialog: MatDialog) {
super();
}
/**
* Opens the dialog. Returns the chosen value after the user accepts.
* @param title The title to display in the dialog
* @param choices The available choices
* @returns an answer {@link ChoiceAnswer}
*/
public async open(
title: string,
choices: ChoiceDialogOptions,
multiSelect: boolean = false
): Promise<ChoiceAnswer> {
const dialogRef = this.dialog.open(ChoiceDialogComponent, {
minWidth: '250px',
maxHeight: '90vh',
data: { title: title, choices: choices, multiSelect: multiSelect }
});
return dialogRef.afterClosed().toPromise();
}
}

View File

@ -1,10 +1,12 @@
import { TestBed, inject } from '@angular/core/testing';
import { ConfigService } from './config.service';
import { E2EImportsModule } from 'e2e-imports.module';
describe('ConfigService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
providers: [ConfigService]
});
});

View File

@ -0,0 +1,32 @@
<div class="scrollmenu-outer">
<h2 mat-dialog-title>{{ data.title | translate }}</h2>
<div class="scrollmenu">
<mat-radio-group
#radio
name="choice"
*ngIf="!data.multiSelect"
class="choice-radio-group"
[(ngModel)]="selectedChoice"
>
<mat-radio-button class="choice-button" *ngFor="let choice of data.choices" [value]="choice.id">
{{ getChoiceTitle(choice) | translate }}
</mat-radio-button>
</mat-radio-group>
<mat-list *ngIf="data.multiSelect">
<mat-list-item *ngFor="let choice of data.choices">
<mat-checkbox [checked]="isChosen(choice)" (change)="toggleChoice(choice)">
{{ getChoiceTitle(choice) | translate }}
</mat-checkbox>
</mat-list-item>
</mat-list>
<span *ngIf="!data.choices.length" translate>No choices available</span>
</div>
<mat-dialog-actions>
<button *ngIf="!data.multiSelect || data.choices.length" mat-button (click)="closeDialog(true)">
<span translate>Ok</span>
</button>
<button mat-button (click)="closeDialog(false)"><span translate>Cancel</span></button>
</mat-dialog-actions>
</div>

View File

@ -0,0 +1,17 @@
mat-radio-group {
display: inline-flex;
flex-direction: column;
mat-radio-button {
margin: 5px;
}
}
.scrollmenu {
padding: 5px;
display: block;
}
.scrollmenu-outer {
max-height: inherit;
}

View File

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

View File

@ -0,0 +1,112 @@
import { Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material';
import { Displayable } from 'app/shared/models/base/displayable';
import { Identifiable } from 'app/shared/models/base/identifiable';
/**
* An option needs to be identifiable and should have a strnig to display. Either uses Displayble or
* a label property.
*/
type ChoiceDialogOption = (Identifiable & Displayable) | (Identifiable & { label: string });
/**
* All choices in the array should have the same type.
*/
export type ChoiceDialogOptions = (Identifiable & Displayable)[] | (Identifiable & { label: string })[];
/**
* All data needed for this dialog
*/
interface ChoiceDialogData {
/**
* A title to display
*/
title: string;
/**
* The choices to display
*/
choices: ChoiceDialogOptions;
/**
* Select, if this should be a multiselect choice
*/
multiSelect: boolean;
}
/**
* undefined is returned, if the dialog is closed. If a choice is submitted,
* it might be a number oder an array of numbers for multiselect.
*/
export type ChoiceAnswer = undefined | number | number[];
/**
* A dialog with choice fields.
*/
@Component({
selector: 'os-choice-dialog',
templateUrl: './choice-dialog.component.html',
styleUrls: ['./choice-dialog.component.scss']
})
export class ChoiceDialogComponent {
/**
* One number selected, if this is a single select choice
*/
public selectedChoice: number;
/**
* All selected ids, if this is a multiselect choice
*/
public selectedMultiChoices: number[] = [];
public constructor(
public dialogRef: MatDialogRef<ChoiceDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: ChoiceDialogData
) {}
/**
* Get the title from a choice. Maybe saved in a label property or using getTitle().
*
* @param choice The choice
* @return the title
*/
public getChoiceTitle(choice: ChoiceDialogOption): string {
if ('label' in choice) {
return choice.label;
} else {
return choice.getTitle();
}
}
/**
* Closes the dialog with the selected choices
*/
public closeDialog(ok: boolean): void {
if (ok) {
this.dialogRef.close(this.data.multiSelect ? this.selectedMultiChoices : this.selectedChoice);
} else {
this.dialogRef.close();
}
}
/**
* For multiSelect: Determines whether a choice has been activated
* @param choice
*/
public isChosen(choice: Identifiable): boolean {
return this.selectedMultiChoices.indexOf(choice.id) >= 0;
}
/**
* For multiSelect: Activates/deactivates a multi-Choice option
* @param choice
*/
public toggleChoice(choice: Identifiable): void {
const idx = this.selectedMultiChoices.indexOf(choice.id);
if (idx < 0) {
this.selectedMultiChoices.push(choice.id);
} else {
this.selectedMultiChoices.splice(idx, 1);
}
}
}

View File

@ -2,12 +2,22 @@
<mat-toolbar color="primary" [ngClass]="multiSelectMode ? 'multi-select' : ''" class="sticky-toolbar">
<div class="toolbar-left">
<!-- Nav menu -->
<button mat-icon-button class="on-transition-fade" *ngIf="vp.isMobile && nav && !multiSelectMode" (click)='clickHamburgerMenu()'>
<button
mat-icon-button
class="on-transition-fade"
*ngIf="vp.isMobile && nav && !multiSelectMode"
(click)="clickHamburgerMenu()"
>
<mat-icon>menu</mat-icon>
</button>
<!-- Exit / Back button -->
<button mat-icon-button class="on-transition-fade" *ngIf="!nav && !editMode && !multiSelectMode" (click)="onBackButton()">
<button
mat-icon-button
class="on-transition-fade"
*ngIf="!nav && !editMode && !multiSelectMode"
(click)="onBackButton()"
>
<mat-icon>arrow_back</mat-icon>
</button>
@ -23,11 +33,11 @@
</div>
<!-- centered information slot -->
<div *ngIf="multiSelectMode" class=spacer></div>
<div *ngIf="!multiSelectMode" class="spacer"></div>
<div class="toolbar-centered on-transition-fade" *ngIf="multiSelectMode">
<ng-content select=".central-info-slot"></ng-content>
</div>
<div class=spacer></div>
<div class="spacer"></div>
<div class="toolbar-right">
<!-- Extra controls slot -->
@ -36,15 +46,18 @@
</div>
<!-- Main action button - desktop -->
<button mat-mini-fab color="accent" class="on-transition-fade"
*ngIf="mainButton && !editMode && !vp.isMobile && !multiSelectMode" (click)="sendMainEvent()">
<button
mat-mini-fab
color="accent"
class="on-transition-fade"
*ngIf="mainButton && !editMode && !vp.isMobile && !multiSelectMode"
(click)="sendMainEvent()"
>
<mat-icon>{{ mainButtonIcon }}</mat-icon>
</button>
<!-- Save button -->
<button mat-button *ngIf="editMode" (click)="save()">
<strong translate class="upper">Save</strong>
</button>
<button mat-button *ngIf="editMode" (click)="save()"><strong translate class="upper">Save</strong></button>
<!-- Menu button slot -->
<ng-content class="on-transition-fade" *ngIf="!editMode" select=".menu-slot"></ng-content>
@ -53,7 +66,11 @@
<!-- Main action button - mobile -->
<button mat-fab class="head-button on-transition-fade"
*ngIf="mainButton && !editMode && vp.isMobile && !multiSelectMode" (click)=sendMainEvent()>
<button
mat-fab
class="head-button on-transition-fade"
*ngIf="mainButton && !editMode && vp.isMobile && !multiSelectMode"
(click)="sendMainEvent()"
>
<mat-icon>{{ mainButtonIcon }}</mat-icon>
</button>

View File

@ -23,7 +23,6 @@ export class Motion extends AgendaBaseModel {
public motion_block_id: number;
public origin: string;
public submitters: MotionSubmitter[];
public submitters_id: number[];
public supporters_id: number[];
public comments: MotionComment[];
public workflow_id: number;

View File

@ -66,6 +66,7 @@ import { PromptDialogComponent } from './components/prompt-dialog/prompt-dialog.
import { SortingListComponent } from './components/sorting-list/sorting-list.component';
import { SpeakerListComponent } from 'app/site/agenda/components/speaker-list/speaker-list.component';
import { SortingTreeComponent } from './components/sorting-tree/sorting-tree.component';
import { ChoiceDialogComponent } from './components/choice-dialog/choice-dialog.component';
/**
* Share Module for all "dumb" components and pipes.
@ -180,7 +181,8 @@ import { SortingTreeComponent } from './components/sorting-tree/sorting-tree.com
PromptDialogComponent,
SortingListComponent,
SpeakerListComponent,
SortingTreeComponent
SortingTreeComponent,
ChoiceDialogComponent
],
providers: [
{ provide: DateAdapter, useClass: OpenSlidesDateAdapter },

View File

@ -1,32 +1,22 @@
<os-head-bar [mainButton]="true" (mainEvent)=onPlusButton() [multiSelectMode]="isMultiSelect">
<os-head-bar [mainButton]="true" (mainEvent)="onPlusButton()" [multiSelectMode]="isMultiSelect">
<!-- Title -->
<div class="title-slot">
<h2 translate>Agenda</h2>
</div>
<div class="title-slot"><h2 translate>Agenda</h2></div>
<!-- Menu -->
<div class="menu-slot">
<button type="button" mat-icon-button [matMenuTriggerFor]="agendaMenu">
<mat-icon>more_vert</mat-icon>
</button>
<button type="button" mat-icon-button [matMenuTriggerFor]="agendaMenu"><mat-icon>more_vert</mat-icon></button>
</div>
<!-- Multiselect info -->
<div class="central-info-slot">
<button mat-icon-button (click)="toggleMultiSelect()">
<mat-icon>arrow_back</mat-icon>
</button>
<button mat-icon-button (click)="toggleMultiSelect()"><mat-icon>arrow_back</mat-icon></button>
<span>{{ selectedRows.length }}&nbsp;</span><span translate>selected</span>
</div>
<div class="extra-controls-slot on-transition-fade" *ngIf="isMultiSelect">
<button mat-icon-button (click)="deleteSelected()">
<mat-icon>delete</mat-icon>
</button>
<button mat-icon-button (click)="deleteSelected()"><mat-icon>delete</mat-icon></button>
</div>
</os-head-bar>
<mat-table class="os-listview-table on-transition-fade" [dataSource]="dataSource" matSort>
<ng-container matColumnDef="selector">
<mat-header-cell *matHeaderCellDef mat-sort-header class="checkbox-cell"></mat-header-cell>
@ -52,9 +42,7 @@
<mat-header-cell *matHeaderCellDef mat-sort-header>Speakers</mat-header-cell>
<mat-cell *matCellDef="let item">
<button mat-icon-button (click)="onSpeakerIcon(item)">
<mat-icon
[matBadge]="item.speakerAmount > 0 ? item.speakerAmount : null"
matBadgeColor="accent">
<mat-icon [matBadge]="item.speakerAmount > 0 ? item.speakerAmount : null" matBadgeColor="accent">
mic
</mat-icon>
</button>
@ -62,8 +50,11 @@
</ng-container>
<mat-header-row *matHeaderRowDef="getColumnDefinition()"></mat-header-row>
<mat-row [ngClass]="selectedRows.indexOf(row) >= 0 ? 'selected': ''" (click)='selectItem(row, $event)'
*matRowDef="let row; columns: getColumnDefinition()"></mat-row>
<mat-row
[ngClass]="selectedRows.indexOf(row) >= 0 ? 'selected' : ''"
(click)="selectItem(row, $event)"
*matRowDef="let row; columns: getColumnDefinition()"
></mat-row>
</mat-table>
<mat-paginator class="on-transition-fade" [pageSizeOptions]="[25, 50, 75, 100, 125]"></mat-paginator>
@ -71,16 +62,12 @@
<div *ngIf="!isMultiSelect">
<button mat-menu-item *osPerms="'agenda.can_manage'" (click)="toggleMultiSelect()">
<mat-icon>library_add</mat-icon>
<span translate>MultiSelect</span>
<span translate>Multiselect</span>
</button>
</div>
<div *ngIf="isMultiSelect">
<div *osPerms="'agenda.can_manage'">
<button mat-menu-item (click)="deleteSelected()">
<mat-icon>delete</mat-icon>
<span translate>Delete selected</span>
</button>
<mat-divider></mat-divider>
<button mat-menu-item (click)="setClosedSelected(true)">
<mat-icon>done</mat-icon>
@ -100,6 +87,11 @@
<mat-icon>visibility_off</mat-icon>
<span translate>Set invisible</span>
</button>
<mat-divider></mat-divider>
<button mat-menu-item class="red-warning-text" (click)="deleteSelected()">
<mat-icon>delete</mat-icon>
<span translate>Delete selected</span>
</button>
</div>
</div>
</mat-menu>

View File

@ -100,6 +100,7 @@ export class AgendaListComponent extends ListViewBaseComponent<ViewItem> impleme
/**
* Sets multiple entries' open/closed state. Needs items in selectedRows, which
* is only filled with any data in multiSelect mode
*
* @param closed true if the item is to be considered done
*/
public async setClosedSelected(closed: boolean): Promise<void> {
@ -111,6 +112,7 @@ export class AgendaListComponent extends ListViewBaseComponent<ViewItem> impleme
/**
* Sets multiple entries' visibility. 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> {

View File

@ -1,9 +1,6 @@
<os-head-bar plusButton=true (plusButtonClicked)=onPlusButton() [multiSelectMode]="isMultiSelect">
<os-head-bar plusButton="true" (plusButtonClicked)="onPlusButton()" [multiSelectMode]="isMultiSelect">
<!-- Title -->
<div class="title-slot">
<h2 translate>Elections</h2>
</div>
<div class="title-slot"><h2 translate>Elections</h2></div>
<!-- Menu -->
<div class="menu-slot">
<button type="button" mat-icon-button [matMenuTriggerFor]="assignmentMenu">
@ -13,21 +10,15 @@
<!-- Multiselect info -->
<div class="central-info-slot">
<button mat-icon-button (click)="toggleMultiSelect()">
<mat-icon>arrow_back</mat-icon>
</button>
<button mat-icon-button (click)="toggleMultiSelect()"><mat-icon>arrow_back</mat-icon></button>
<span>{{ selectedRows.length }}&nbsp;</span><span translate>selected</span>
</div>
<div class="extra-controls-slot on-transition-fade" *ngIf="isMultiSelect">
<button mat-icon-button (click)="deleteSelected()">
<mat-icon>delete</mat-icon>
</button>
<button mat-icon-button (click)="deleteSelected()"><mat-icon>delete</mat-icon></button>
</div>
</os-head-bar>
<mat-table class="os-listview-table on-transition-fade" [dataSource]="dataSource" matSort>
<ng-container matColumnDef="selector">
<mat-header-cell *matHeaderCellDef mat-sort-header class="checkbox-cell"></mat-header-cell>
@ -58,8 +49,11 @@
</ng-container>
<mat-header-row *matHeaderRowDef="getColumnDefintion()"></mat-header-row>
<mat-row [ngClass]="selectedRows.indexOf(row) >= 0 ? 'selected': ''" (click)='selectItem(row, $event)'
*matRowDef="let row; columns: getColumnDefintion()">
<mat-row
[ngClass]="selectedRows.indexOf(row) >= 0 ? 'selected' : ''"
(click)="selectItem(row, $event)"
*matRowDef="let row; columns: getColumnDefintion()"
>
</mat-row>
</mat-table>
@ -69,7 +63,7 @@
<div *ngIf="!isMultiSelect">
<button mat-menu-item *osPerms="'assignment.can_manage'" (click)="toggleMultiSelect()">
<mat-icon>library_add</mat-icon>
<span translate>MultiSelect</span>
<span translate>Multiselect</span>
</button>
<button mat-menu-item (click)="downloadAssignmentButton()">
<mat-icon>archive</mat-icon>
@ -78,7 +72,12 @@
</div>
<div *ngIf="isMultiSelect">
<button mat-menu-item *osPerms="'assignment.can_manage'" (click)="deleteSelected()">
<button mat-menu-item (click)="toggleMultiSelect()">
<mat-icon>library_add</mat-icon>
<span translate>Exit multiselect</span>
</button>
<mat-divider></mat-divider>
<button mat-menu-item class="red-warning-text" *osPerms="'assignment.can_manage'" (click)="deleteSelected()">
<mat-icon>delete</mat-icon>
<span translate>Delete assignments</span>
</button>

View File

@ -25,7 +25,7 @@ describe('ResetPasswordConfirmComponent', () => {
beforeEach(() => {
fixture = TestBed.createComponent(ResetPasswordConfirmComponent);
component = fixture.componentInstance;
fixture.detectChanges();
// fixture.detectChanges();
});
it('should create', () => {

View File

@ -5,7 +5,6 @@
(mainEvent)="onMainEvent()"
(saveEvent)="onSaveEditedFile()"
>
<!-- Title -->
<div class="title-slot">
<h2 *ngIf="!editFile" translate>Files</h2>
@ -44,31 +43,18 @@
<mat-icon>more_vert</mat-icon>
</button>
</div>
<!-- Multiselect menu -->
<div class="multiselect-menu-slot">
<button type="button" mat-icon-button [matMenuTriggerFor]="mediafilesMultiSelectMenu">
<mat-icon>more_vert</mat-icon>
</button>
</div>
<!-- Multiselect info -->
<div *ngIf="this.isMultiSelect" class="central-info-slot">
<button mat-icon-button (click)="toggleMultiSelect()">
<mat-icon >arrow_back</mat-icon>
</button>
<button mat-icon-button (click)="toggleMultiSelect()"><mat-icon>arrow_back</mat-icon></button>
<span>{{ selectedRows.length }}&nbsp;</span><span translate>selected</span>
</div>
<div class="extra-controls-slot on-transition-fade" *ngIf="isMultiSelect">
<button mat-icon-button (click)="deleteSelected()">
<mat-icon>delete</mat-icon>
</button>
<button mat-icon-button (click)="deleteSelected()"><mat-icon>delete</mat-icon></button>
</div>
</os-head-bar>
<mat-table class="os-listview-table on-transition-fade" [dataSource]="dataSource" matSort>
<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">
@ -122,8 +108,11 @@
</ng-container>
<mat-header-row *matHeaderRowDef="getColumnDefinition()"></mat-header-row>
<mat-row *matRowDef="let row; columns: getColumnDefinition()"
[ngClass]="selectedRows.indexOf(row) >= 0 ? 'selected': ''" (click)='selectItem(row, $event)'></mat-row>
<mat-row
*matRowDef="let row; columns: getColumnDefinition()"
[ngClass]="selectedRows.indexOf(row) >= 0 ? 'selected' : ''"
(click)="selectItem(row, $event)"
></mat-row>
</mat-table>
<mat-paginator class="on-transition-fade" [pageSizeOptions]="[25, 50, 75, 100, 125]"></mat-paginator>
@ -167,14 +156,21 @@
<!-- Menu for Mediafiles -->
<mat-menu #mediafilesMenu="matMenu">
<div *ngIf="!isMultiSelect">
<button mat-menu-item *osPerms="'mediafiles.can_manage'" (click)="toggleMultiSelect()">
<mat-icon>library_add</mat-icon>
<span translate>MultiSelect</span>
<span translate>Multiselect</span>
</button>
</mat-menu>
<mat-menu #mediafilesMultiSelectMenu="matMenu">
</div>
<div *ngIf="isMultiSelect">
<button mat-menu-item (click)="toggleMultiSelect()">
<mat-icon>library_add</mat-icon>
<span translate>Exit multiselect</span>
</button>
<mat-divider></mat-divider>
<button mat-menu-item *osPerms="'mediafiles.can_manage'" (click)="deleteSelected()">
<mat-icon>delete</mat-icon>
<span translate>Delete selected</span>
</button>
</div>
</mat-menu>

View File

@ -9,8 +9,8 @@ import { TranslateService } from '@ngx-translate/core';
import { MotionRepositoryService } from '../../services/motion-repository.service';
import { ViewMotion } from '../../models/view-motion';
import { LinenumberingService } from '../../services/linenumbering.service';
import { Motion } from '../../../../shared/models/motions/motion';
import { BaseViewComponent } from '../../../base/base-view';
import { CreateMotion } from '../../models/create-motion';
/**
* Describes the single paragraphs from the base motion.
@ -167,10 +167,10 @@ export class AmendmentCreateWizardComponent extends BaseViewComponent {
amendment_paragraphs: amendedParagraphs
};
const fromForm = new Motion();
fromForm.deserialize(newMotionValues);
const motion = new CreateMotion();
motion.deserialize(newMotionValues);
const response = await this.repo.create(fromForm);
const response = await this.repo.create(motion);
this.router.navigate(['./motions/' + response.id]);
}
}

View File

@ -29,6 +29,8 @@ import { ConfigService } from '../../../../core/services/config.service';
import { Workflow } from 'app/shared/models/motions/workflow';
import { take, takeWhile, multicast, skipWhile } from 'rxjs/operators';
import { LocalPermissionsService } from '../../services/local-permissions.service';
import { ViewCreateMotion } from '../../models/view-create-motion';
import { CreateMotion } from '../../models/create-motion';
/**
* Component for the motion detail view
@ -260,28 +262,10 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit {
}
});
// load config variables
this.configService.get('motions_statutes_enabled').subscribe(
(enabled: boolean): void => {
this.statutesEnabled = enabled;
}
);
this.configService.get('motions_min_supporters').subscribe(
(supporters: number): void => {
this.minSupporters = supporters;
}
);
this.configService.get('motions_preamble').subscribe(
(preamble: string): void => {
this.preamble = preamble;
}
);
this.configService.get('motions_amendments_enabled').subscribe(
(enabled: boolean): void => {
this.amendmentsEnabled = enabled;
}
);
this.configService.get('motions_statutes_enabled').subscribe(enabled => (this.statutesEnabled = enabled));
this.configService.get('motions_min_supporters').subscribe(supporters => (this.minSupporters = supporters));
this.configService.get('motions_preamble').subscribe(preamble => (this.preamble = preamble));
this.configService.get('motions_amendments_enabled').subscribe(enabled => (this.amendmentsEnabled = enabled));
}
/**
@ -327,8 +311,8 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit {
// creates a new motion
this.newMotion = true;
this.editMotion = true;
this.motion = new ViewMotion();
this.motionCopy = new ViewMotion();
this.motion = new ViewCreateMotion();
this.motionCopy = new ViewCreateMotion();
} else {
// load existing motion
this.route.params.subscribe(params => {
@ -393,7 +377,7 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit {
state_id: [''],
recommendation_id: [''],
submitters_id: [],
supporters_id: [],
supporters_id: [[]],
workflow_id: [],
origin: ['']
});
@ -419,45 +403,65 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit {
}
/**
* Save a motion. Calls the "patchValues" function in the MotionObject
*
* http:post the motion to the server.
* The AutoUpdate-Service should see a change once it arrives and show it
* in the list view automatically
* Before updating or creating, the motions needs to be prepared for paragraph based amendments.
* A motion of type T is created, prepared and deserialized from the given motionValues
*
* @param motionValues valus for the new motion
* @param ctor The motion constructor, so different motion types can be created.
*/
public async saveMotion(): Promise<void> {
const newMotionValues = { ...this.metaInfoForm.value, ...this.contentForm.value };
const fromForm = new Motion();
private prepareMotionForSave<T extends Motion>(motionValues: any, ctor: new (...args: any[]) => T): T {
const motion = new ctor();
if (this.motion.isParagraphBasedAmendment()) {
fromForm.amendment_paragraphs = this.motion.amendment_paragraphs.map(
(para: string): string => {
if (para === null) {
motion.amendment_paragraphs = this.motion.amendment_paragraphs.map(
(paragraph: string): string => {
if (paragraph === null) {
return null;
} else {
return newMotionValues.text;
return motionValues.text;
}
}
);
newMotionValues.text = '';
motionValues.text = '';
}
fromForm.deserialize(newMotionValues);
motion.deserialize(motionValues);
return motion;
}
/**
* Creates a motion. Calls the "patchValues" function in the MotionObject
*/
public async createMotion(): Promise<void> {
const newMotionValues = { ...this.metaInfoForm.value, ...this.contentForm.value };
const motion = this.prepareMotionForSave(newMotionValues, CreateMotion);
try {
if (this.newMotion) {
const response = await this.repo.create(fromForm);
const response = await this.repo.create(motion);
this.router.navigate(['./motions/' + response.id]);
} else {
await this.repo.update(fromForm, this.motionCopy);
// if the motion was successfully updated, change the edit mode.
this.editMotion = false;
}
} catch (e) {
this.raiseError(e);
}
}
/**
* Save a motion. Calls the "patchValues" function in the MotionObject
*/
public async updateMotion(): Promise<void> {
const newMotionValues = { ...this.metaInfoForm.value, ...this.contentForm.value };
const motion = this.prepareMotionForSave(newMotionValues, Motion);
this.repo.update(motion, this.motionCopy).then(() => (this.editMotion = false), this.raiseError);
}
/**
* In the ui are no distinct buttons for update or create. This is decided here.
*/
public saveMotion(): void {
if (this.newMotion) {
this.createMotion();
} else {
this.updateMotion();
}
}
/**
* get the formated motion text from the repository.
*/

View File

@ -1,8 +1,6 @@
<os-head-bar [mainButton]="true" (mainEvent)=onPlusButton() [multiSelectMode]="isMultiSelect">
<os-head-bar [mainButton]="true" (mainEvent)="onPlusButton()" [multiSelectMode]="isMultiSelect">
<!-- Title -->
<div class="title-slot">
<h2 translate>Motions</h2>
</div>
<div class="title-slot"><h2 translate>Motions</h2></div>
<!-- Menu -->
<div class="menu-slot">
@ -13,27 +11,13 @@
<!-- Multiselect info -->
<div class="central-info-slot">
<button mat-icon-button (click)="toggleMultiSelect()">
<mat-icon>arrow_back</mat-icon>
</button>
<button mat-icon-button (click)="toggleMultiSelect()"><mat-icon>arrow_back</mat-icon></button>
<span>{{ selectedRows.length }}&nbsp;</span><span translate>selected</span>
</div>
<div class="extra-controls-slot on-transition-fade" *ngIf="isMultiSelect">
<button mat-icon-button (click)="deleteSelected()">
<mat-icon>delete</mat-icon>
</button>
</div>
</os-head-bar>
<div class="custom-table-header on-transition-fade">
<button mat-button>
<span translate>SORT</span>
</button>
<button mat-button>
<span translate>FILTER</span>
</button>
<button mat-button><span translate>SORT</span></button> <button mat-button><span translate>FILTER</span></button>
</div>
<mat-table class="os-listview-table on-transition-fade" [dataSource]="dataSource" matSort>
@ -48,9 +32,7 @@
<ng-container matColumnDef="identifier">
<mat-header-cell *matHeaderCellDef mat-sort-header>Identifier</mat-header-cell>
<mat-cell *matCellDef="let motion">
<div class="innerTable">
{{ motion.identifier }}
</div>
<div class="innerTable">{{ motion.identifier }}</div>
</mat-cell>
</ng-container>
@ -59,25 +41,28 @@
<mat-header-cell *matHeaderCellDef mat-sort-header>Title</mat-header-cell>
<mat-cell *matCellDef="let motion">
<div class="innerTable">
<span class="motion-list-title">{{ motion.title }}</span>
<br>
<span class="motion-list-from">
<span translate>by</span>
{{ motion.submitters }}
<span class="motion-list-title">{{ motion.title }}</span> <br />
<span class="motion-list-from" *ngIf="motion.submitters.length">
<span translate>by</span> {{ motion.submitters }}
</span>
<br>
<br *ngIf="motion.submitters.length" />
<!-- state -->
<mat-basic-chip [ngClass]="{
'green': motion.state.css_class === 'success',
'red': motion.state.css_class === 'danger',
'grey': motion.state.css_class === 'default',
'lightblue': motion.state.css_class === 'primary' }">
<mat-basic-chip
[ngClass]="{
green: motion.state.css_class === 'success',
red: motion.state.css_class === 'danger',
grey: motion.state.css_class === 'default',
lightblue: motion.state.css_class === 'primary'
}"
>
{{ motion.state.name | translate }}
</mat-basic-chip>
<!-- recommendation -->
<span *ngIf="motion.recommendation">
<mat-basic-chip class="bluegrey">{{ motion.recommendation.recommendation_label | translate }}</mat-basic-chip>
<mat-basic-chip class="bluegrey">{{
motion.recommendation.recommendation_label | translate
}}</mat-basic-chip>
</span>
</div>
</mat-cell>
@ -86,10 +71,8 @@
<!-- state column -->
<ng-container matColumnDef="state">
<mat-header-cell *matHeaderCellDef mat-sort-header>State</mat-header-cell>
<mat-cell *matCellDef="let motion" (click)="selectMotion(motion)">
<div *ngIf='motion.category' class='small'>
<mat-icon>device_hub</mat-icon> {{ motion.category }}
</div>
<mat-cell *matCellDef="let motion">
<div *ngIf="motion.category" class="small"><mat-icon>device_hub</mat-icon>{{ motion.category }}</div>
</mat-cell>
</ng-container>
@ -100,7 +83,8 @@
<button mat-icon-button (click)="onSpeakerIcon(motion, $event)">
<mat-icon
[matBadge]="motion.agendaSpeakerAmount > 0 ? motion.agendaSpeakerAmount : null"
matBadgeColor="accent">
matBadgeColor="accent"
>
mic
</mat-icon>
</button>
@ -108,8 +92,12 @@
</ng-container>
<mat-header-row *matHeaderRowDef="getColumnDefinition()"></mat-header-row>
<mat-row [ngClass]="selectedRows.indexOf(row) >= 0 ? 'selected': ''" (click)='selectItem(row, $event)'
*matRowDef="let row; columns: getColumnDefinition()" class="lg">
<mat-row
[ngClass]="selectedRows.indexOf(row) >= 0 ? 'selected' : ''"
(click)="selectItem(row, $event)"
*matRowDef="let row; columns: getColumnDefinition()"
class="lg"
>
</mat-row>
</mat-table>
@ -119,7 +107,7 @@
<div *ngIf="!isMultiSelect">
<button mat-menu-item *osPerms="'motions.can_manage'" (click)="toggleMultiSelect()">
<mat-icon>library_add</mat-icon>
<span translate>MultiSelect</span>
<span translate>Multiselect</span>
</button>
<button mat-menu-item routerLink="category">
<mat-icon>device_hub</mat-icon>
@ -139,25 +127,66 @@
</button>
</div>
<div *ngIf="isMultiSelect">
<div *osPerms="'motions.can_manage'">
<button mat-menu-item (click)="deleteSelected()">
<mat-icon>delete</mat-icon>
<span translate>Delete selected</span>
<button mat-menu-item (click)="toggleMultiSelect()">
<mat-icon>library_add</mat-icon>
<span translate>Exit multiselect</span>
</button>
<button mat-menu-item (click)="openSetStatusMenu()">
<mat-icon>sentiment_satisfied</mat-icon>
<!-- TODO: icon -->
<div *osPerms="'motions.can_manage'">
<mat-divider></mat-divider>
<button mat-menu-item (click)="multiselectWrapper(multiselectService.moveToItem(selectedRows))">
<!-- TODO: Not implemented yet -->
<mat-icon>sort</mat-icon>
<span translate>Move to agenda item</span>
</button>
<button mat-menu-item (click)="multiselectWrapper(multiselectService.setStatus(selectedRows))">
<mat-icon>label</mat-icon>
<span translate>Set status</span>
</button>
<button mat-menu-item (click)="openSetCategoryMenu()">
<mat-icon>sentiment_satisfied</mat-icon>
<button mat-menu-item (click)="multiselectWrapper(multiselectService.setRecommendation(selectedRows))">
<mat-icon>report</mat-icon>
<!-- TODO: better icon -->
<span translate>Set recommendation</span>
</button>
<button mat-menu-item (click)="multiselectWrapper(multiselectService.setCategory(selectedRows))">
<mat-icon>device_hub</mat-icon>
<!-- TODO: icon -->
<span translate>Set categories</span>
</button>
<mat-divider></mat-divider>
<button mat-menu-item (click)="multiselectWrapper(multiselectService.addSubmitters(selectedRows))">
<mat-icon>person_add</mat-icon>
<!-- TODO: icon -->
<span translate>Add submitters</span>
</button>
<button mat-menu-item (click)="multiselectWrapper(multiselectService.removeSubmitters(selectedRows))">
<mat-icon>person_outline</mat-icon>
<!-- TODO: icon -->
<span translate>remove submitters</span>
</button>
<button mat-menu-item (click)="multiselectWrapper(multiselectService.addTags(selectedRows))">
<mat-icon>bookmarks</mat-icon>
<!-- TODO: icon -->
<span translate>Add tags</span>
</button>
<button mat-menu-item (click)="multiselectWrapper(multiselectService.removeTags(selectedRows))">
<mat-icon>bookmark_border</mat-icon>
<!-- TODO: icon -->
<span translate>Remove tags</span>
</button>
</div>
<button mat-menu-item (click)="csvExportMotionList()">
<mat-divider></mat-divider>
<button mat-menu-item (click)="csvExportMotionList(); toggleMultiSelect()">
<mat-icon>archive</mat-icon>
<span translate>Export as CSV</span>
</button>
<mat-divider></mat-divider>
<button
mat-menu-item
class="red-warning-text"
(click)="multiselectService.delete(selectedRows); toggleMultiSelect()"
>
<mat-icon>delete</mat-icon>
<span translate>Delete selected</span>
</button>
</div>
</mat-menu>

View File

@ -3,15 +3,14 @@ import { Router, ActivatedRoute } from '@angular/router';
import { Title } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core';
import { ConfigService } from '../../../../core/services/config.service';
import { MotionCsvExportService } from '../../services/motion-csv-export.service';
import { ListViewBaseComponent } from '../../../base/list-view-base';
import { MatSnackBar } from '@angular/material';
import { MotionRepositoryService } from '../../services/motion-repository.service';
import { ViewMotion } from '../../models/view-motion';
import { WorkflowState } from '../../../../shared/models/motions/workflow-state';
import { ListViewBaseComponent } from '../../../base/list-view-base';
import { MatSnackBar } from '@angular/material';
import { ConfigService } from '../../../../core/services/config.service';
import { Category } from '../../../../shared/models/motions/category';
import { PromptService } from '../../../../core/services/prompt.service';
import { MotionCsvExportService } from '../../services/motion-csv-export.service';
import { MotionMultiselectService } from '../../services/motion-multiselect.service';
/**
* Component that displays all the motions in a Table using DataSource.
@ -51,8 +50,13 @@ export class MotionListComponent extends ListViewBaseComponent<ViewMotion> imple
* @param route Current route
* @param configService The configuration provider
* @param repo Motion Repository
* @param csvExport CSV Export Service
* @param promptService
* @param motionCsvExport
* @param workflowRepo Workflow Repository
* @param categoryRepo
* @param userRepo
* @param tagRepo
* @param choiceService
*/
public constructor(
titleService: Title,
@ -62,8 +66,8 @@ export class MotionListComponent extends ListViewBaseComponent<ViewMotion> imple
private route: ActivatedRoute,
private configService: ConfigService,
private repo: MotionRepositoryService,
private promptService: PromptService,
private motionCsvExport: MotionCsvExportService
private motionCsvExport: MotionCsvExportService,
public multiselectService: MotionMultiselectService
) {
super(titleService, translate, matSnackBar);
@ -90,11 +94,7 @@ export class MotionListComponent extends ListViewBaseComponent<ViewMotion> imple
}
});
});
this.configService.get('motions_statutes_enabled').subscribe(
(enabled: boolean): void => {
this.statutesEnabled = enabled;
}
);
this.configService.get('motions_statutes_enabled').subscribe(enabled => (this.statutesEnabled = enabled));
}
/**
@ -164,60 +164,21 @@ export class MotionListComponent extends ListViewBaseComponent<ViewMotion> imple
}
/**
* Deletes the items selected.
* SelectedRows is only filled with data in multiSelect mode
* Returns current definitions for the listView table
*/
public async deleteSelected(): Promise<void> {
const content = this.translate.instant('This will delete all selected motions.');
if (await this.promptService.open('Are you sure?', content)) {
for (const motion of this.selectedRows) {
await this.repo.delete(motion);
}
}
}
/**
* Set the status in bulk.
* SelectedRows is only filled with data in multiSelect mode
* TODO: currently not yet functional, because no status (or state_id) is being selected
* in the ui
* @param status TODO: May still change type
*/
public async setStatusSelected(status: Partial<WorkflowState>): Promise<void> {
// TODO: check if id is there
for (const motion of this.selectedRows) {
await this.repo.update({ state_id: status.id }, motion);
}
}
/**
* Set the category for all selected items.
* SelectedRows is only filled with data in multiSelect mode
* TODO: currently not yet functional, because no category is being selected in the ui
* @param category TODO: May still change type
*/
public async setCategorySelected(category: Partial<Category>): Promise<void> {
for (const motion of this.selectedRows) {
await this.repo.update({ state_id: category.id }, motion);
}
}
/**
* TODO: Open an extra submenu. Design still undecided. Will be used for deciding
* the status of setStatusSelected
*/
public openSetStatusMenu(): void {}
/**
* TODO: Open an extra submenu. Design still undecided. Will be used for deciding
* the status of setCategorySelected
*/
public openSetCategoryMenu(): void {}
public getColumnDefinition(): string[] {
if (this.isMultiSelect) {
return ['selector'].concat(this.columnsToDisplayMinWidth);
}
return this.columnsToDisplayMinWidth;
}
/**
* Wraps multiselect actions to close the multiselect mode or throw an error if one happens.
*
* @param multiselectPromise The promise returned by multiselect actions.
*/
public multiselectWrapper(multiselectPromise: Promise<void>): void {
multiselectPromise.then(() => this.toggleMultiSelect(), this.raiseError);
}
}

View File

@ -0,0 +1,13 @@
import { Motion } from 'app/shared/models/motions/motion';
/**
* Representation of Motion during creation. The submitters_id is added to send this information
* as an array of user ids to the server.
*/
export class CreateMotion extends Motion {
public submitters_id: number[];
public constructor(input?: any) {
super(input);
}
}

View File

@ -0,0 +1,62 @@
import { Category } from '../../../shared/models/motions/category';
import { User } from '../../../shared/models/users/user';
import { Workflow } from '../../../shared/models/motions/workflow';
import { WorkflowState } from '../../../shared/models/motions/workflow-state';
import { Item } from 'app/shared/models/agenda/item';
import { MotionBlock } from 'app/shared/models/motions/motion-block';
import { ViewMotion } from './view-motion';
import { CreateMotion } from './create-motion';
/**
* Create motion class for the View. Its different to ViewMotion in fact that the submitter handling is different
* on motion creation.
*
* @ignore
*/
export class ViewCreateMotion extends ViewMotion {
protected _motion: CreateMotion;
public get motion(): CreateMotion {
return this._motion;
}
public get submitters(): User[] {
return this._submitters;
}
public get submitters_id(): number[] {
return this.motion ? this.motion.submitters_id : null;
}
public set submitters(users: User[]) {
this._submitters = users;
this._motion.submitters_id = users.map(user => user.id);
}
public constructor(
motion?: CreateMotion,
category?: Category,
submitters?: User[],
supporters?: User[],
workflow?: Workflow,
state?: WorkflowState,
item?: Item,
block?: MotionBlock
) {
super(motion, category, submitters, supporters, workflow, state, item, block);
}
/**
* Duplicate this motion into a copy of itself
*/
public copy(): ViewCreateMotion {
return new ViewCreateMotion(
this._motion,
this._category,
this._submitters,
this._supporters,
this._workflow,
this._state
);
}
}

View File

@ -31,14 +31,14 @@ export enum ChangeRecoMode {
* @ignore
*/
export class ViewMotion extends BaseViewModel {
private _motion: Motion;
private _category: Category;
private _submitters: User[];
private _supporters: User[];
private _workflow: Workflow;
private _state: WorkflowState;
private _item: Item;
private _block: MotionBlock;
protected _motion: Motion;
protected _category: Category;
protected _submitters: User[];
protected _supporters: User[];
protected _workflow: Workflow;
protected _state: WorkflowState;
protected _item: Item;
protected _block: MotionBlock;
/**
* Indicates the LineNumberingMode Mode.
@ -126,7 +126,7 @@ export class ViewMotion extends BaseViewModel {
}
public get submitters_id(): number[] {
return this.motion ? this.motion.submitters_id : null;
return this.motion ? this.motion.submitterIds : null;
}
public get supporters(): User[] {
@ -184,11 +184,6 @@ export class ViewMotion extends BaseViewModel {
this._motion.supporters_id = users.map(user => user.id);
}
public set submitters(users: User[]) {
this._submitters = users;
this._motion.submitters_id = users.map(user => user.id);
}
public get item(): Item {
return this._item;
}
@ -213,6 +208,10 @@ export class ViewMotion extends BaseViewModel {
return this.motion && this.motion.amendment_paragraphs ? this.motion.amendment_paragraphs : [];
}
public get tags_id(): number[] {
return this._motion ? this._motion.tags_id : null;
}
public constructor(
motion?: Motion,
category?: Category,

View File

@ -0,0 +1,58 @@
import { Workflow } from '../../../shared/models/motions/workflow';
import { WorkflowState } from '../../../shared/models/motions/workflow-state';
import { BaseViewModel } from '../../base/base-view-model';
/**
* class for the ViewWorkflow. Currently only a basic stub
*
* Stores a Category including all (implicit) references
* Provides "safe" access to variables and functions in {@link Category}
* @ignore
*/
export class ViewWorkflow extends BaseViewModel {
private _workflow: Workflow;
public constructor(workflow?: Workflow) {
super();
this._workflow = workflow;
}
public get workflow(): Workflow {
return this._workflow;
}
public get id(): number {
return this.workflow ? this.workflow.id : null;
}
public get name(): string {
return this.workflow ? this.workflow.name : null;
}
public get states(): WorkflowState[] {
return this.workflow ? this.workflow.states : null;
}
public get first_state(): number {
return this.workflow ? this.workflow.first_state : null;
}
public getTitle(): string {
return this.name;
}
/**
* Duplicate this motion into a copy of itself
*/
public copy(): ViewWorkflow {
return new ViewWorkflow(this._workflow);
}
/**
* Updates the local objects if required
* @param update
*/
public updateValues(update: Workflow): void {
this._workflow = update;
}
}

View File

@ -1,4 +1,5 @@
import { Injectable } from '@angular/core';
import { Category } from '../../../shared/models/motions/category';
import { ViewCategory } from '../models/view-category';
import { DataSendService } from '../../../core/services/data-send.service';

View File

@ -0,0 +1,17 @@
import { TestBed, inject } from '@angular/core/testing';
import { E2EImportsModule } from 'e2e-imports.module';
import { MotionMultiselectService } from './motion-multiselect.service';
describe('MotionMultiselectService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
providers: [MotionMultiselectService]
});
});
it('should be created', inject([MotionMultiselectService], (service: MotionMultiselectService) => {
expect(service).toBeTruthy();
}));
});

View File

@ -0,0 +1,224 @@
import { Injectable } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { ViewMotion } from '../models/view-motion';
import { ChoiceService } from 'app/core/services/choice.service';
import { PromptService } from 'app/core/services/prompt.service';
import { MotionRepositoryService } from './motion-repository.service';
import { UserRepositoryService } from 'app/site/users/services/user-repository.service';
import { WorkflowRepositoryService } from './workflow-repository.service';
import { CategoryRepositoryService } from './category-repository.service';
import { TagRepositoryService } from 'app/site/tags/services/tag-repository.service';
import { HttpService } from 'app/core/services/http.service';
import { AgendaRepositoryService } from 'app/site/agenda/services/agenda-repository.service';
import { Displayable } from 'app/shared/models/base/displayable';
import { Identifiable } from 'app/shared/models/base/identifiable';
/**
* Contains all multiselect actions for the motion list view.
*/
@Injectable({
providedIn: 'root'
})
export class MotionMultiselectService {
/**
* Does nothing.
*
* @param repo MotionRepositoryService
* @param translate TranslateService
* @param promptService
* @param choiceService
* @param userRepo
* @param workflowRepo
* @param categoryRepo
* @param tagRepo
*/
public constructor(
private repo: MotionRepositoryService,
private translate: TranslateService,
private promptService: PromptService,
private choiceService: ChoiceService,
private userRepo: UserRepositoryService,
private workflowRepo: WorkflowRepositoryService,
private categoryRepo: CategoryRepositoryService,
private tagRepo: TagRepositoryService,
private agendaRepo: AgendaRepositoryService,
private httpService: HttpService
) {}
/**
* Deletes the given motions. Asks for confirmation.
*
* @param motions The motions to delete
*/
public async delete(motions: ViewMotion[]): Promise<void> {
const content = this.translate.instant('This will delete all selected motions.');
if (await this.promptService.open('Are you sure?', content)) {
for (const motion of motions) {
await this.repo.delete(motion);
}
}
}
/**
* Moves the related agenda items from the motions as childs under a selected (parent) agenda item.
*/
public async moveToItem(motions: ViewMotion[]): Promise<void> {
const title = this.translate.instant('This will move all selected motions as childs to:');
const choices: (Displayable & Identifiable)[] = this.agendaRepo.getViewModelList();
const selectedChoice = await this.choiceService.open(title, choices);
if (selectedChoice) {
const requestData = {
items: motions.map(motion => motion.agenda_item_id),
parent_id: selectedChoice as number
};
await this.httpService.post('/rest/agenda/item/assign', requestData);
}
}
/**
* Opens a dialog and then sets the status for all motions.
*
* @param motions The motions to change
*/
public async setStatus(motions: ViewMotion[]): Promise<void> {
const title = this.translate.instant('This will set the state of all selected motions to:');
const choices = this.workflowRepo.getAllWorkflowStates().map(workflowState => ({
id: workflowState.id,
label: workflowState.name
}));
const selectedChoice = await this.choiceService.open(title, choices);
if (selectedChoice) {
for (const motion of motions) {
await this.repo.setState(motion, selectedChoice as number);
}
}
}
/**
* Opens a dialog and sets the recommendation to the users choice for all selected motions.
*
* @param motions The motions to change
*/
public async setRecommendation(motions: ViewMotion[]): Promise<void> {
const title = this.translate.instant('This will set the recommendation for all selected motions to:');
const choices = this.workflowRepo
.getAllWorkflowStates()
.filter(workflowState => !!workflowState.recommendation_label)
.map(workflowState => ({
id: workflowState.id,
label: workflowState.recommendation_label
}));
choices.push({ id: 0, label: 'Delete recommendation' });
const selectedChoice = await this.choiceService.open(title, choices);
if (typeof selectedChoice === 'number') {
const requestData = motions.map(motion => ({
id: motion.id,
recommendation: selectedChoice
}));
await this.httpService.post('/rest/motions/motion/manage_multiple_recommendation', {
motions: requestData
});
}
}
/**
* Opens a dialog and sets the category for all given motions.
*
* @param motions The motions to change
*/
public async setCategory(motions: ViewMotion[]): Promise<void> {
const title = this.translate.instant('This will set the category of all selected motions to:');
const selectedChoice = await this.choiceService.open(title, this.categoryRepo.getViewModelList());
if (selectedChoice) {
for (const motion of motions) {
await this.repo.update({ category_id: selectedChoice as number }, motion);
}
}
}
/**
* Opens a dialog and adds the selected submitters for all given motions.
*
* @param motions The motions to add the sumbitters to
*/
public async addSubmitters(motions: ViewMotion[]): Promise<void> {
const title = this.translate.instant('This will add the following submitters of all selected motions:');
const selectedChoice = await this.choiceService.open(title, this.userRepo.getViewModelList(), true);
if (selectedChoice) {
const requestData = motions.map(motion => {
let submitterIds = [...motion.submitters_id, ...(selectedChoice as number[])];
submitterIds = submitterIds.filter((id, index, self) => self.indexOf(id) === index); // remove duplicates
return {
id: motion.id,
submitters: submitterIds
};
});
await this.httpService.post('/rest/motions/motion/manage_multiple_submitters', { motions: requestData });
}
}
/**
* Opens a dialog and removes the selected submitters for all given motions.
*
* @param motions The motions to remove the submitters from
*/
public async removeSubmitters(motions: ViewMotion[]): Promise<void> {
const title = this.translate.instant('This will remove the following submitters from all selected motions:');
const selectedChoice = await this.choiceService.open(title, this.userRepo.getViewModelList(), true);
if (selectedChoice) {
const requestData = motions.map(motion => {
const submitterIdsToRemove = selectedChoice as number[];
const submitterIds = motion.submitters_id.filter(id => !submitterIdsToRemove.includes(id));
return {
id: motion.id,
submitters: submitterIds
};
});
await this.httpService.post('/rest/motions/motion/manage_multiple_submitters', { motions: requestData });
}
}
/**
* Opens a dialog and adds the selected tags for all given motions.
*
* @param motions The motions to add the tags to
*/
public async addTags(motions: ViewMotion[]): Promise<void> {
const title = this.translate.instant('This will add the following tags to all selected motions:');
const selectedChoice = await this.choiceService.open(title, this.tagRepo.getViewModelList(), true);
if (selectedChoice) {
const requestData = motions.map(motion => {
let tagIds = [...motion.tags_id, ...(selectedChoice as number[])];
tagIds = tagIds.filter((id, index, self) => self.indexOf(id) === index); // remove duplicates
return {
id: motion.id,
tags: tagIds
};
});
await this.httpService.post('/rest/motions/motion/manage_multiple_tags', { motions: requestData });
}
}
/**
* Opens a dialog and removes the selected tags for all given motions.
*
* @param motions The motions to remove the tags from
*/
public async removeTags(motions: ViewMotion[]): Promise<void> {
const title = this.translate.instant('This will remove the following tags from all selected motions:');
const selectedChoice = await this.choiceService.open(title, this.tagRepo.getViewModelList(), true);
if (selectedChoice) {
const requestData = motions.map(motion => {
const tagIdsToRemove = selectedChoice as number[];
const tagIds = motion.tags_id.filter(id => !tagIdsToRemove.includes(id));
return {
id: motion.id,
tags: tagIds
};
});
await this.httpService.post('/rest/motions/motion/manage_multiple_tags', { motions: requestData });
}
}
}

View File

@ -25,6 +25,7 @@ import { Item } from 'app/shared/models/agenda/item';
import { OSTreeSortEvent } from 'app/shared/components/sorting-tree/sorting-tree.component';
import { TreeService } from 'app/core/services/tree.service';
import { ViewMotionAmendedParagraph } from '../models/view-motion-amended-paragraph';
import { CreateMotion } from '../models/create-motion';
/**
* Repository Services for motions (and potentially categories)
@ -109,12 +110,8 @@ export class MotionRepositoryService extends BaseRepository<ViewMotion, Motion>
*
* @param update the form data containing the updated values
* @param viewMotion The View Motion. If not present, a new motion will be created
* TODO: Remove the viewMotion and make it actually distignuishable from save()
*/
public async create(motion: Motion): Promise<Identifiable> {
if (!motion.supporters_id) {
delete motion.supporters_id;
}
public async create(motion: CreateMotion): Promise<Identifiable> {
return await this.dataSend.createModel(motion);
}

View File

@ -0,0 +1,17 @@
import { TestBed, inject } from '@angular/core/testing';
import { WorkflowRepositoryService } from './workflow-repository.service';
import { E2EImportsModule } from '../../../../e2e-imports.module';
describe('WorkflowRepositoryService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
providers: [WorkflowRepositoryService]
});
});
it('should be created', inject([WorkflowRepositoryService], (service: WorkflowRepositoryService) => {
expect(service).toBeTruthy();
}));
});

View File

@ -0,0 +1,73 @@
import { Injectable } from '@angular/core';
import { Workflow } from '../../../shared/models/motions/workflow';
import { ViewWorkflow } from '../models/view-workflow';
import { DataSendService } from '../../../core/services/data-send.service';
import { DataStoreService } from '../../../core/services/data-store.service';
import { BaseRepository } from '../../base/base-repository';
import { Identifiable } from '../../../shared/models/base/identifiable';
import { CollectionStringModelMapperService } from '../../../core/services/collectionStringModelMapper.service';
import { WorkflowState } from 'app/shared/models/motions/workflow-state';
/**
* Repository Services for Categories
*
* The repository is meant to process domain objects (those found under
* shared/models), so components can display them and interact with them.
*
* Rather than manipulating models directly, the repository is meant to
* inform the {@link DataSendService} about changes which will send
* them to the Server.
*/
@Injectable({
providedIn: 'root'
})
export class WorkflowRepositoryService extends BaseRepository<ViewWorkflow, Workflow> {
/**
* Creates a WorkflowRepository
* Converts existing and incoming workflow to ViewWorkflows
* @param DS
* @param dataSend
*/
public constructor(
DS: DataStoreService,
mapperService: CollectionStringModelMapperService,
private dataSend: DataSendService
) {
super(DS, mapperService, Workflow);
}
protected createViewModel(workflow: Workflow): ViewWorkflow {
return new ViewWorkflow(workflow);
}
public async create(newWorkflow: Workflow): Promise<Identifiable> {
return await this.dataSend.createModel(newWorkflow);
}
public async update(workflow: Partial<Workflow>, viewWorkflow: ViewWorkflow): Promise<void> {
let updateWorkflow: Workflow;
if (viewWorkflow) {
updateWorkflow = viewWorkflow.workflow;
} else {
updateWorkflow = new Workflow();
}
updateWorkflow.patchValues(workflow);
await this.dataSend.updateModel(updateWorkflow);
}
public async delete(viewWorkflow: ViewWorkflow): Promise<void> {
const workflow = viewWorkflow.workflow;
await this.dataSend.deleteModel(workflow);
}
/**
* Collects all existing states from all workflows
*/
public getAllWorkflowStates(): WorkflowState[] {
let states: WorkflowState[] = [];
this.getViewModelList().forEach(workflow => {
states = states.concat(workflow.states);
});
return states;
}
}

View File

@ -1,4 +1,5 @@
import { Injectable } from '@angular/core';
import { Tag } from '../../../shared/models/core/tag';
import { ViewTag } from '../models/view-tag';
import { DataSendService } from '../../../core/services/data-send.service';

View File

@ -259,18 +259,10 @@ export class UserDetailComponent extends BaseViewComponent implements OnInit {
/**
* Handler for the generate Password button.
* Generates a password using 8 pseudo-random letters
* from the `characters` const.
*/
public generatePassword(): void {
let pw = '';
const characters = 'abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
const amount = 8;
for (let i = 0; i < amount; i++) {
pw += characters.charAt(Math.floor(Math.random() * characters.length));
}
this.personalInfoForm.patchValue({
default_password: pw
default_password: this.repo.getRandomPassword()
});
}

View File

@ -1,39 +1,25 @@
<os-head-bar mainButton=true (mainEvent)=onPlusButton() [multiSelectMode]="isMultiSelect">
<os-head-bar mainButton="true" (mainEvent)="onPlusButton()" [multiSelectMode]="isMultiSelect">
<!-- Title -->
<div class="title-slot">
<h2 translate>Participants</h2>
</div>
<div class="title-slot"><h2 translate>Participants</h2></div>
<!-- Menu -->
<div class="menu-slot">
<button type="button" mat-icon-button [matMenuTriggerFor]="userMenu">
<mat-icon>more_vert</mat-icon>
</button>
<button type="button" mat-icon-button [matMenuTriggerFor]="userMenu"><mat-icon>more_vert</mat-icon></button>
</div>
<!-- Multiselect info -->
<div class="central-info-slot">
<button mat-icon-button (click)="toggleMultiSelect()">
<mat-icon>arrow_back</mat-icon>
</button>
<button mat-icon-button (click)="toggleMultiSelect()"><mat-icon>arrow_back</mat-icon></button>
<span>{{ selectedRows.length }}&nbsp;</span><span translate>selected</span>
</div>
<div class="extra-controls-slot on-transition-fade" *ngIf="isMultiSelect">
<button mat-icon-button (click)="deleteSelected()">
<mat-icon>delete</mat-icon>
</button>
<button mat-icon-button (click)="deleteSelected()"><mat-icon>delete</mat-icon></button>
</div>
</os-head-bar>
<div class="custom-table-header on-transition-fade">
<button mat-button>
<span translate>SORT</span>
</button>
<button mat-button>
<span translate>FILTER</span>
</button>
<button mat-button><span translate>SORT</span></button> <button mat-button><span translate>FILTER</span></button>
</div>
<mat-table class="os-listview-table on-transition-fade" [dataSource]="dataSource" matSort>
@ -54,12 +40,12 @@
<ng-container matColumnDef="group">
<mat-header-cell *matHeaderCellDef mat-sort-header>Group</mat-header-cell>
<mat-cell *matCellDef="let user">
<div class='groupsCell'>
<div class="groupsCell">
<span *ngIf="user.groups.length > 0">
<mat-icon>people</mat-icon>
{{ user.groups }}
</span>
<br *ngIf="user.groups && user.structureLevel">
<br *ngIf="user.groups && user.structureLevel" />
<span *ngIf="user.structureLevel">
<mat-icon>flag</mat-icon>
{{ user.structure_level }}
@ -80,8 +66,11 @@
</ng-container>
<mat-header-row *matHeaderRowDef="getColumnDefinition()"></mat-header-row>
<mat-row [ngClass]="selectedRows.indexOf(row) >= 0 ? 'selected': ''" (click)='selectItem(row, $event)'
*matRowDef="let row; columns: getColumnDefinition()">
<mat-row
[ngClass]="selectedRows.indexOf(row) >= 0 ? 'selected' : ''"
(click)="selectItem(row, $event)"
*matRowDef="let row; columns: getColumnDefinition()"
>
</mat-row>
</mat-table>
@ -91,7 +80,7 @@
<div *ngIf="!isMultiSelect">
<button mat-menu-item *osPerms="'users.can_manage'" (click)="toggleMultiSelect()">
<mat-icon>library_add</mat-icon>
<span translate>MultiSelect</span>
<span translate>Multiselect</span>
</button>
<button mat-menu-item *osPerms="'users.can_manage'" routerLink="groups">
@ -110,11 +99,19 @@
</button>
</div>
<div *ngIf="isMultiSelect">
<button mat-menu-item (click)="toggleMultiSelect()">
<mat-icon>library_add</mat-icon>
<span translate>Exit multiselect</span>
</button>
<div *osPerms="'users.can_manage'">
<button mat-menu-item (click)="setGroupSelected(null)">
<mat-icon>archive</mat-icon>
<span translate>Set groups</span>
<!-- TODO bottomsheet/menu? -->
<mat-divider></mat-divider>
<button mat-menu-item (click)="setGroupSelected(true)">
<mat-icon>people</mat-icon>
<span translate>Add groups</span>
</button>
<button mat-menu-item (click)="setGroupSelected(false)">
<mat-icon>people_outline</mat-icon>
<span translate>Remove groups</span>
</button>
<mat-divider></mat-divider>
@ -123,44 +120,41 @@
<mat-icon>add_circle</mat-icon>
<span translate>Set active</span>
</button>
<button mat-menu-item (click)="setActiveSelected(false)">
<mat-icon>remove_circle</mat-icon>
<span translate>Set inactive</span>
</button>
<mat-divider></mat-divider>
<button mat-menu-item (click)="setPresentSelected(true)">
<mat-icon>add_circle</mat-icon>
<mat-icon>check_box</mat-icon>
<span translate>Set as present</span>
</button>
<button mat-menu-item (click)="setPresentSelected(false)">
<mat-icon>remove_circle</mat-icon>
<mat-icon>check_box_outline_blank</mat-icon>
<span translate>Set as not present</span>
</button>
<mat-divider></mat-divider>
<button mat-menu-item (click)="setCommitteeSelected(true)">
<mat-icon>add_circle</mat-icon>
<mat-icon>account_balance</mat-icon>
<span translate>Set as committee</span>
</button>
<button mat-menu-item (click)="setCommitteeSelected(false)">
<mat-icon>remove_circle</mat-icon>
<mat-icon>account_balance</mat-icon>
<span translate>Unset committee</span>
</button>
<mat-divider></mat-divider>
<button mat-menu-item (click)="sendInvitationSelected()">
<button mat-menu-item (click)="sendInvitationEmailSelected()">
<mat-icon>mail</mat-icon>
<span translate>Send invitations</span>
<span translate>Send invitation emails</span>
</button>
<button mat-menu-item (click)="deleteSelected()">
<button mat-menu-item (click)="resetPasswordsSelected()">
<mat-icon>vpn_key</mat-icon>
<span translate>Generate new passwords</span>
</button>
<mat-divider></mat-divider>
<button mat-menu-item class="red-warning-text" (click)="deleteSelected()">
<mat-icon>delete</mat-icon>
<span translate>Delete selected</span>
</button>

View File

@ -1,15 +1,16 @@
import { Component, OnInit } from '@angular/core';
import { MatSnackBar } from '@angular/material';
import { Router, ActivatedRoute } from '@angular/router';
import { Title } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core';
import { CsvExportService } from '../../../../core/services/csv-export.service';
import { ViewUser } from '../../models/view-user';
import { UserRepositoryService } from '../../services/user-repository.service';
import { CsvExportService } from '../../../../core/services/csv-export.service';
import { ListViewBaseComponent } from '../../../base/list-view-base';
import { Router, ActivatedRoute } from '@angular/router';
import { MatSnackBar } from '@angular/material';
import { Group } from '../../../../shared/models/users/group';
import { GroupRepositoryService } from '../../services/group-repository.service';
import { PromptService } from '../../../../core/services/prompt.service';
import { UserRepositoryService } from '../../services/user-repository.service';
import { ViewUser } from '../../models/view-user';
import { ChoiceService } from '../../../../core/services/choice.service';
/**
* Component for the user list view.
@ -28,6 +29,7 @@ export class UserListComponent extends ListViewBaseComponent<ViewUser> implement
* @param translate Service for translation handling
* @param matSnackBar Helper to diplay errors
* @param repo the user repository
* @param groupRepo: The user group repository
* @param router the router service
* @param route the local route
* @param csvExport CSV export Service,
@ -38,6 +40,8 @@ export class UserListComponent extends ListViewBaseComponent<ViewUser> implement
translate: TranslateService,
matSnackBar: MatSnackBar,
private repo: UserRepositoryService,
private groupRepo: GroupRepositoryService,
private choiceService: ChoiceService,
private router: Router,
private route: ActivatedRoute,
protected csvExport: CsvExportService,
@ -124,31 +128,31 @@ export class UserListComponent extends ListViewBaseComponent<ViewUser> implement
}
/**
* TODO: Not yet as expected
* Bulk sets the group for users. TODO: Group is still not decided in the ui
* @param group TODO: type may still change
* @param unset toggle for adding or removing from the group
* Opens a dialog and sets the group(s) for all selected users.
* SelectedRows is only filled with data in multiSelect mode
*/
public async setGroupSelected(group: Partial<Group>, unset?: boolean): Promise<void> {
this.selectedRows.forEach(vm => {
const groups = vm.groupIds;
const idx = groups.indexOf(group.id);
if (unset && idx >= 0) {
groups.slice(idx, 1);
} else if (!unset && idx < 0) {
groups.push(group.id);
public async setGroupSelected(add: boolean): Promise<void> {
let content: string;
if (add) {
content = this.translate.instant('This will add the following groups to all selected users:');
} else {
content = this.translate.instant('This will remove the following groups from all selected users:');
}
const selectedChoice = await this.choiceService.open(content, this.groupRepo.getViewModelList(), true);
if (selectedChoice) {
for (const user of this.selectedRows) {
const newGroups = [...user.groups_id];
(selectedChoice as number[]).forEach(newChoice => {
const idx = newGroups.indexOf(newChoice);
if (idx < 0 && add) {
newGroups.push(newChoice);
} else if (idx >= 0 && !add) {
newGroups.slice(idx, 1);
}
});
await this.repo.update({ groups_id: newGroups }, user);
}
}
/**
* Handler for bulk resetting passwords. Needs multiSelect mode.
* TODO: Not yet implemented (no service yet)
*/
public async resetPasswordsSelected(): Promise<void> {
// for (const user of this.selectedRows) {
// await this.resetPassword(user);
// }
}
/**
@ -183,12 +187,20 @@ export class UserListComponent extends ListViewBaseComponent<ViewUser> implement
/**
* Handler for bulk sending e-mail invitations. Uses selectedRows defined via
* multiSelect mode. TODO: Not yet implemented (no service)
* multiSelect mode.
*/
public async sendInvitationSelected(): Promise<void> {
// this.selectedRows.forEach(vm => {
// TODO if !vm.emailSent {vm.sendInvitation}
// });
public sendInvitationEmailSelected(): void {
this.repo.sendInvitationEmail(this.selectedRows).then(this.raiseError, this.raiseError);
}
/**
* Handler for bulk resetting passwords. Needs multiSelect mode.
*/
public async resetPasswordsSelected(): Promise<void> {
for (const user of this.selectedRows) {
const password = this.repo.getRandomPassword();
this.repo.resetPassword(user, password);
}
}
public getColumnDefinition(): string[] {

View File

@ -39,6 +39,10 @@ export class ViewUser extends BaseViewModel {
return this.user ? this.user.full_name : null;
}
public get short_name(): string {
return this.user ? this.user.short_name : null;
}
public get email(): string {
return this.user ? this.user.email : null;
}

View File

@ -1,13 +1,13 @@
import { Injectable } from '@angular/core';
import { ViewGroup } from '../models/view-group';
import { BaseRepository } from '../../base/base-repository';
import { Group } from '../../../shared/models/users/group';
import { DataStoreService } from '../../../core/services/data-store.service';
import { DataSendService } from '../../../core/services/data-send.service';
import { ConstantsService } from '../../../core/services/constants.service';
import { Identifiable } from '../../../shared/models/base/identifiable';
import { CollectionStringModelMapperService } from '../../../core/services/collectionStringModelMapper.service';
import { ConstantsService } from '../../../core/services/constants.service';
import { DataSendService } from '../../../core/services/data-send.service';
import { DataStoreService } from '../../../core/services/data-store.service';
import { Group } from '../../../shared/models/users/group';
import { Identifiable } from '../../../shared/models/base/identifiable';
import { ViewGroup } from '../models/view-group';
/**
* Set rules to define the shape of an app permission

View File

@ -1,5 +1,4 @@
import { Injectable } from '@angular/core';
import { BaseRepository } from '../../base/base-repository';
import { ViewUser } from '../models/view-user';
import { User } from '../../../shared/models/users/user';
@ -8,6 +7,9 @@ import { DataStoreService } from '../../../core/services/data-store.service';
import { DataSendService } from '../../../core/services/data-send.service';
import { Identifiable } from '../../../shared/models/base/identifiable';
import { CollectionStringModelMapperService } from '../../../core/services/collectionStringModelMapper.service';
import { ConfigService } from 'app/core/services/config.service';
import { HttpService } from 'app/core/services/http.service';
import { TranslateService } from '@ngx-translate/core';
/**
* Repository service for users
@ -24,7 +26,10 @@ export class UserRepositoryService extends BaseRepository<ViewUser, User> {
public constructor(
DS: DataStoreService,
mapperService: CollectionStringModelMapperService,
private dataSend: DataSendService
private dataSend: DataSendService,
private translate: TranslateService,
private httpService: HttpService,
private configService: ConfigService
) {
super(DS, mapperService, User, [Group]);
}
@ -84,4 +89,93 @@ export class UserRepositoryService extends BaseRepository<ViewUser, User> {
const groups = this.DS.getMany(Group, user.groups_id);
return new ViewUser(user, groups);
}
/**
* Generates a random password
*
* @param length THe length of the password to generate
* @returns a random password
*/
public getRandomPassword(length: number = 8): string {
let pw = '';
const characters = 'abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
for (let i = 0; i < length; i++) {
pw += characters.charAt(Math.floor(Math.random() * characters.length));
}
return pw;
}
/**
* Updates the default password and sets the real password.
*
* @param user The user to update
* @param password The password to set
*/
public async resetPassword(user: ViewUser, password: string): Promise<void> {
await this.update({ default_password: password }, user);
const path = `/rest/users/user/${user.id}/reset_password/`;
await this.httpService.post(path, { password: password });
}
/**
* Sends invitation emails to all given users. Returns a prepared string to show the user.
* This string should always be shown, becuase even in success cases, some users may not get
* an email and the user should be notified about this.
*
* @param users All affected users
*/
public async sendInvitationEmail(users: ViewUser[]): Promise<string> {
const user_ids = users.map(user => user.id);
const subject = this.translate.instant(this.configService.instant('users_email_subject'));
const message = this.translate.instant(this.configService.instant('users_email_body'));
const response = await this.httpService.post<{ count: Number; no_email_ids: number[] }>(
'/rest/users/user/mass_invite_email/',
{
user_ids: user_ids,
subject: subject,
message: message
}
);
const numEmails = response.count;
const noEmailIds = response.no_email_ids;
let msg;
if (numEmails === 0) {
msg = this.translate.instant('No emails were send.');
} else if (numEmails === 1) {
msg = this.translate.instant('One email was send sucessfully.');
} else {
msg = this.translate.instant('%num% emails were send sucessfully.').replace('%num%', numEmails);
}
if (noEmailIds.length) {
msg += ' ';
if (noEmailIds.length === 1) {
msg += this.translate.instant(
'The user %user% has no email, so the invitation email could not be send.'
);
} else {
msg += this.translate.instant(
'The users %user% have no email, so the invitation emails could not be send.'
);
}
// This one builds a username string like "user1, user2 and user3" with the full names.
const usernames = noEmailIds
.map(id => this.getViewModel(id))
.filter(user => !!user)
.map(user => user.short_name);
let userString;
if (usernames.length > 1) {
const lastUsername = usernames.pop();
userString = usernames.join(', ') + ' ' + this.translate.instant('and') + ' ' + lastUsername;
} else {
userString = usernames.join(', ');
}
msg = msg.replace('%user%', userString);
}
return msg;
}
}