Added new multiselect actions.

This commit is contained in:
Emanuel Schütze 2018-11-27 22:44:37 +01:00 committed by FinnStutzenstein
parent 0a823877c2
commit 82b26347e2
30 changed files with 834 additions and 181 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,35 @@
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',
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,24 @@
<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>

View File

@ -0,0 +1,15 @@
mat-radio-group {
display: inline-flex;
flex-direction: column;
mat-radio-button {
margin: 5px;
}
}
.scrollmenu {
padding: 4px;
overflow-y: auto;
display: block;
overflow-y: auto;
}

View File

@ -0,0 +1,26 @@
import { async, ComponentFixture, 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,92 @@
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 })[];
interface ChoiceDialogData {
title: string;
choices: ChoiceDialogOptions;
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
) {}
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

@ -23,7 +23,7 @@
</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>

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

@ -71,16 +71,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 +96,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

@ -69,7 +69,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 +78,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

@ -44,14 +44,6 @@
<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()">
@ -167,14 +159,21 @@
<!-- Menu for Mediafiles -->
<mat-menu #mediafilesMenu="matMenu">
<button mat-menu-item *osPerms="'mediafiles.can_manage'" (click)="toggleMultiSelect()">
<div *ngIf="!isMultiSelect">
<button mat-menu-item *osPerms="'mediafiles.can_manage'" (click)="toggleMultiSelect()">
<mat-icon>library_add</mat-icon>
<span translate>Multiselect</span>
</button>
</div>
<div *ngIf="isMultiSelect">
<button mat-menu-item (click)="toggleMultiSelect()">
<mat-icon>library_add</mat-icon>
<span translate>MultiSelect</span>
</button>
</mat-menu>
<mat-menu #mediafilesMultiSelectMenu="matMenu">
<button mat-menu-item *osPerms="'mediafiles.can_manage'" (click)="deleteSelected()">
<mat-icon>delete</mat-icon>
<span translate>Delete selected</span>
</button>
<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

@ -18,13 +18,6 @@
</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">
@ -86,9 +79,9 @@
<!-- 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)">
<mat-cell *matCellDef="let motion">
<div *ngIf='motion.category' class='small'>
<mat-icon>device_hub</mat-icon> {{ motion.category }}
<mat-icon>device_hub</mat-icon>{{ motion.category }}
</div>
</mat-cell>
</ng-container>
@ -119,7 +112,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 +132,62 @@
</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="'motions.can_manage'">
<button mat-menu-item (click)="deleteSelected()">
<mat-icon>delete</mat-icon>
<span translate>Delete selected</span>
<mat-divider></mat-divider>
<button mat-menu-item>
<!-- TODO: Not implemented yet -->
<mat-icon>sort</mat-icon>
<span translate>Move to agenda item</span>
</button>
<button mat-menu-item (click)="openSetStatusMenu()">
<mat-icon>sentiment_satisfied</mat-icon>
<!-- TODO: icon -->
<button mat-menu-item (click)="multiselectService.setStatus(selectedRows); toggleMultiSelect()">
<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)="multiselectService.setRecommendation(selectedRows); toggleMultiSelect()">
<mat-icon>report</mat-icon>
<!-- TODO: better icon -->
<span translate>Set recommendation</span>
</button>
<button mat-menu-item (click)="multiselectService.setCategory(selectedRows); toggleMultiSelect()">
<mat-icon>device_hub</mat-icon>
<!-- TODO: icon -->
<span translate>Set categories</span>
</button>
<mat-divider></mat-divider>
<button mat-menu-item (click)="multiselectService.addSubmitters(selectedRows); toggleMultiSelect()">
<mat-icon>person_add</mat-icon>
<!-- TODO: icon -->
<span translate>Add submitters</span>
</button>
<button mat-menu-item (click)="multiselectService.removeSubmitters(selectedRows); toggleMultiSelect()">
<mat-icon>person_outline</mat-icon>
<!-- TODO: icon -->
<span translate>remove submitters</span>
</button>
<button mat-menu-item (click)="multiselectService.addTags(selectedRows); toggleMultiSelect()">
<mat-icon>bookmarks</mat-icon>
<!-- TODO: icon -->
<span translate>Add tags</span>
</button>
<button mat-menu-item (click)="multiselectService.removeTags(selectedRows); toggleMultiSelect()">
<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,56 +164,8 @@ 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);

View File

@ -153,6 +153,12 @@ export class ViewMotion extends BaseViewModel {
return this.motion && this.motion.state_id ? this.motion.state_id : null;
}
public get possibleStates(): WorkflowState[] {
return this.workflow
? this.workflow.states
: null;
}
public get recommendation_id(): number {
return this.motion && this.motion.recommendation_id ? this.motion.recommendation_id : null;
}
@ -213,6 +219,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,63 @@
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, id?: number, name?: string) {
super();
if (!workflow) {
workflow = new Workflow();
workflow.id = id;
workflow.name = name;
}
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,20 @@
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,173 @@
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';
/**
* 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
) {}
/**
* 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);
}
}
}
/**
* 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
}));
const selectedChoice = await this.choiceService.open(title, choices);
if (selectedChoice) {
for (const motion of motions) {
await this.repo.setRecommendation(motion, selectedChoice as number);
}
}
}
/**
* 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) {
throw new Error("Not implemented on the server");
}
}
/**
* 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) {
throw new Error("Not implemented on the server");
}
}
/**
* 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) {
for (const motion of motions) {
let tagIds = [...motion.tags_id, ...(selectedChoice as number[])];
tagIds = tagIds.filter((id, index, self) => self.indexOf(id) === index);
await this.repo.update({ tags_id: tagIds }, motion);
}
}
}
/**
* 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) {
for (const motion of motions) {
const tagIdsToRemove = selectedChoice as number[];
const tagIds = motion.tags_id.filter(id => !tagIdsToRemove.includes(id));
await this.repo.update({ tags_id: tagIds }, 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,80 @@
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(
protected 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);
}
/**
* Returns the workflow for the ID
* @param workflow_id workflow ID
*/
public getWorkflowByID(workflow_id: number): Workflow {
const wfList = this.DS.getAll(Workflow);
return wfList.find(workflow => workflow.id === workflow_id);
}
public getAllWorkflowStates(): WorkflowState[]{
let states: WorkflowState[] = [];
this.DS.getAll(Workflow).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

@ -91,7 +91,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 +110,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 +131,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,10 +40,12 @@ 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,
private promptService: PromptService
private promptService: PromptService,
) {
super(titleService, translate, matSnackBar);
@ -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,70 @@ 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;
}
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 });
}
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;
}
}