From 82b26347e2a235680261e95ba0d0eaf19d000f91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emanuel=20Sch=C3=BCtze?= Date: Tue, 27 Nov 2018 22:44:37 +0100 Subject: [PATCH] Added new multiselect actions. --- client/src/app/core/core.module.ts | 3 +- .../app/core/services/choice.service.spec.ts | 18 ++ .../src/app/core/services/choice.service.ts | 35 ++++ .../app/core/services/config.service.spec.ts | 2 + .../choice-dialog.component.html | 24 +++ .../choice-dialog.component.scss | 15 ++ .../choice-dialog.component.spec.ts | 26 +++ .../choice-dialog/choice-dialog.component.ts | 92 ++++++++++ .../head-bar/head-bar.component.html | 2 +- client/src/app/shared/shared.module.ts | 4 +- .../agenda-list/agenda-list.component.html | 11 +- .../agenda-list/agenda-list.component.ts | 2 + .../assignment-list.component.html | 9 +- .../mediafile-list.component.html | 33 ++-- .../motion-list/motion-list.component.html | 68 +++++-- .../motion-list/motion-list.component.ts | 78 ++------ .../app/site/motions/models/view-motion.ts | 10 + .../app/site/motions/models/view-workflow.ts | 63 +++++++ .../services/category-repository.service.ts | 1 + .../motion-multiselect.service.spec.ts | 20 ++ .../services/motion-multiselect.service.ts | 173 ++++++++++++++++++ .../workflow-repository.service.spec.ts | 17 ++ .../services/workflow-repository.service.ts | 80 ++++++++ .../tags/services/tag-repository.service.ts | 1 + .../user-detail/user-detail.component.ts | 10 +- .../user-list/user-list.component.html | 45 +++-- .../user-list/user-list.component.ts | 82 +++++---- client/src/app/site/users/models/view-user.ts | 4 + .../services/group-repository.service.ts | 12 +- .../users/services/user-repository.service.ts | 75 +++++++- 30 files changed, 834 insertions(+), 181 deletions(-) create mode 100644 client/src/app/core/services/choice.service.spec.ts create mode 100644 client/src/app/core/services/choice.service.ts create mode 100644 client/src/app/shared/components/choice-dialog/choice-dialog.component.html create mode 100644 client/src/app/shared/components/choice-dialog/choice-dialog.component.scss create mode 100644 client/src/app/shared/components/choice-dialog/choice-dialog.component.spec.ts create mode 100644 client/src/app/shared/components/choice-dialog/choice-dialog.component.ts create mode 100644 client/src/app/site/motions/models/view-workflow.ts create mode 100644 client/src/app/site/motions/services/motion-multiselect.service.spec.ts create mode 100644 client/src/app/site/motions/services/motion-multiselect.service.ts create mode 100644 client/src/app/site/motions/services/workflow-repository.service.spec.ts create mode 100644 client/src/app/site/motions/services/workflow-repository.service.ts diff --git a/client/src/app/core/core.module.ts b/client/src/app/core/core.module.ts index 0691b51a2..7c7745185 100644 --- a/client/src/app/core/core.module.ts +++ b/client/src/app/core/core.module.ts @@ -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 */ diff --git a/client/src/app/core/services/choice.service.spec.ts b/client/src/app/core/services/choice.service.spec.ts new file mode 100644 index 000000000..891322fde --- /dev/null +++ b/client/src/app/core/services/choice.service.spec.ts @@ -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(); + }); +}); diff --git a/client/src/app/core/services/choice.service.ts b/client/src/app/core/services/choice.service.ts new file mode 100644 index 000000000..62dfc311d --- /dev/null +++ b/client/src/app/core/services/choice.service.ts @@ -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 { + const dialogRef = this.dialog.open(ChoiceDialogComponent, { + minWidth: '250px', + data: { title: title, choices: choices, multiSelect: multiSelect } + }); + return dialogRef.afterClosed().toPromise(); + } +} diff --git a/client/src/app/core/services/config.service.spec.ts b/client/src/app/core/services/config.service.spec.ts index 90426ee20..81eea56a2 100644 --- a/client/src/app/core/services/config.service.spec.ts +++ b/client/src/app/core/services/config.service.spec.ts @@ -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] }); }); diff --git a/client/src/app/shared/components/choice-dialog/choice-dialog.component.html b/client/src/app/shared/components/choice-dialog/choice-dialog.component.html new file mode 100644 index 000000000..979829e44 --- /dev/null +++ b/client/src/app/shared/components/choice-dialog/choice-dialog.component.html @@ -0,0 +1,24 @@ +

{{ data.title | translate }}

+
+ + + {{ getChoiceTitle(choice) | translate}} + + + + + + + {{ getChoiceTitle(choice) | translate }} + + + + + No choices available +
+ + + + diff --git a/client/src/app/shared/components/choice-dialog/choice-dialog.component.scss b/client/src/app/shared/components/choice-dialog/choice-dialog.component.scss new file mode 100644 index 000000000..0c013a045 --- /dev/null +++ b/client/src/app/shared/components/choice-dialog/choice-dialog.component.scss @@ -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; +} diff --git a/client/src/app/shared/components/choice-dialog/choice-dialog.component.spec.ts b/client/src/app/shared/components/choice-dialog/choice-dialog.component.spec.ts new file mode 100644 index 000000000..5b07c62a4 --- /dev/null +++ b/client/src/app/shared/components/choice-dialog/choice-dialog.component.spec.ts @@ -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; + + 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(); +});*/ +}); diff --git a/client/src/app/shared/components/choice-dialog/choice-dialog.component.ts b/client/src/app/shared/components/choice-dialog/choice-dialog.component.ts new file mode 100644 index 000000000..0cab85381 --- /dev/null +++ b/client/src/app/shared/components/choice-dialog/choice-dialog.component.ts @@ -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, + @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); + } + } +} diff --git a/client/src/app/shared/components/head-bar/head-bar.component.html b/client/src/app/shared/components/head-bar/head-bar.component.html index 575dd3f56..9fe62d832 100644 --- a/client/src/app/shared/components/head-bar/head-bar.component.html +++ b/client/src/app/shared/components/head-bar/head-bar.component.html @@ -23,7 +23,7 @@ -
+
diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts index 1100680e4..69fdc20a6 100644 --- a/client/src/app/shared/shared.module.ts +++ b/client/src/app/shared/shared.module.ts @@ -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 }, diff --git a/client/src/app/site/agenda/components/agenda-list/agenda-list.component.html b/client/src/app/site/agenda/components/agenda-list/agenda-list.component.html index 9c79e730e..d08aef20c 100644 --- a/client/src/app/site/agenda/components/agenda-list/agenda-list.component.html +++ b/client/src/app/site/agenda/components/agenda-list/agenda-list.component.html @@ -71,16 +71,12 @@
- + +
diff --git a/client/src/app/site/agenda/components/agenda-list/agenda-list.component.ts b/client/src/app/site/agenda/components/agenda-list/agenda-list.component.ts index dc382bd79..1a3209411 100644 --- a/client/src/app/site/agenda/components/agenda-list/agenda-list.component.ts +++ b/client/src/app/site/agenda/components/agenda-list/agenda-list.component.ts @@ -100,6 +100,7 @@ export class AgendaListComponent extends ListViewBaseComponent 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 { @@ -111,6 +112,7 @@ export class AgendaListComponent extends ListViewBaseComponent 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 { diff --git a/client/src/app/site/assignments/assignment-list/assignment-list.component.html b/client/src/app/site/assignments/assignment-list/assignment-list.component.html index 125909d60..7b46800de 100644 --- a/client/src/app/site/assignments/assignment-list/assignment-list.component.html +++ b/client/src/app/site/assignments/assignment-list/assignment-list.component.html @@ -69,7 +69,7 @@
- + + diff --git a/client/src/app/site/mediafiles/components/mediafile-list/mediafile-list.component.html b/client/src/app/site/mediafiles/components/mediafile-list/mediafile-list.component.html index a98624a83..b4348caa7 100644 --- a/client/src/app/site/mediafiles/components/mediafile-list/mediafile-list.component.html +++ b/client/src/app/site/mediafiles/components/mediafile-list/mediafile-list.component.html @@ -44,14 +44,6 @@ more_vert
- - -
- -
-
+
+
+ - - - + Exit multiselect + + + +
diff --git a/client/src/app/site/motions/components/motion-list/motion-list.component.html b/client/src/app/site/motions/components/motion-list/motion-list.component.html index c3f0a0755..a2cd77cd5 100644 --- a/client/src/app/site/motions/components/motion-list/motion-list.component.html +++ b/client/src/app/site/motions/components/motion-list/motion-list.component.html @@ -18,13 +18,6 @@ {{ selectedRows.length }} selected - -
- -
-
@@ -86,9 +79,9 @@ State - +
- device_hub {{ motion.category }} + device_hub{{ motion.category }}
@@ -119,7 +112,7 @@
+
- - - + + + + + +
- + +
diff --git a/client/src/app/site/motions/components/motion-list/motion-list.component.ts b/client/src/app/site/motions/components/motion-list/motion-list.component.ts index 2284a622b..3bfd0ceeb 100644 --- a/client/src/app/site/motions/components/motion-list/motion-list.component.ts +++ b/client/src/app/site/motions/components/motion-list/motion-list.component.ts @@ -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 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 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 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 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 { - 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): Promise { - // 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): Promise { - 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); diff --git a/client/src/app/site/motions/models/view-motion.ts b/client/src/app/site/motions/models/view-motion.ts index 91d93bbc6..c53e4852d 100644 --- a/client/src/app/site/motions/models/view-motion.ts +++ b/client/src/app/site/motions/models/view-motion.ts @@ -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, diff --git a/client/src/app/site/motions/models/view-workflow.ts b/client/src/app/site/motions/models/view-workflow.ts new file mode 100644 index 000000000..98cce8aaf --- /dev/null +++ b/client/src/app/site/motions/models/view-workflow.ts @@ -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; + } +} diff --git a/client/src/app/site/motions/services/category-repository.service.ts b/client/src/app/site/motions/services/category-repository.service.ts index ec966d5ea..229531136 100644 --- a/client/src/app/site/motions/services/category-repository.service.ts +++ b/client/src/app/site/motions/services/category-repository.service.ts @@ -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'; diff --git a/client/src/app/site/motions/services/motion-multiselect.service.spec.ts b/client/src/app/site/motions/services/motion-multiselect.service.spec.ts new file mode 100644 index 000000000..39f58bfdf --- /dev/null +++ b/client/src/app/site/motions/services/motion-multiselect.service.spec.ts @@ -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(); + } + )); +}); diff --git a/client/src/app/site/motions/services/motion-multiselect.service.ts b/client/src/app/site/motions/services/motion-multiselect.service.ts new file mode 100644 index 000000000..1d0b9d61d --- /dev/null +++ b/client/src/app/site/motions/services/motion-multiselect.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); + } + } + } +} diff --git a/client/src/app/site/motions/services/workflow-repository.service.spec.ts b/client/src/app/site/motions/services/workflow-repository.service.spec.ts new file mode 100644 index 000000000..f8209ab4b --- /dev/null +++ b/client/src/app/site/motions/services/workflow-repository.service.spec.ts @@ -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(); + })); +}); diff --git a/client/src/app/site/motions/services/workflow-repository.service.ts b/client/src/app/site/motions/services/workflow-repository.service.ts new file mode 100644 index 000000000..298b19776 --- /dev/null +++ b/client/src/app/site/motions/services/workflow-repository.service.ts @@ -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 { + /** + * 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 { + return await this.dataSend.createModel(newWorkflow); + } + + public async update(workflow: Partial, viewWorkflow: ViewWorkflow): Promise { + 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 { + 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; + } + +} diff --git a/client/src/app/site/tags/services/tag-repository.service.ts b/client/src/app/site/tags/services/tag-repository.service.ts index 902574335..81264e13d 100644 --- a/client/src/app/site/tags/services/tag-repository.service.ts +++ b/client/src/app/site/tags/services/tag-repository.service.ts @@ -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'; diff --git a/client/src/app/site/users/components/user-detail/user-detail.component.ts b/client/src/app/site/users/components/user-detail/user-detail.component.ts index 57dd73600..7fe4494c5 100644 --- a/client/src/app/site/users/components/user-detail/user-detail.component.ts +++ b/client/src/app/site/users/components/user-detail/user-detail.component.ts @@ -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() }); } diff --git a/client/src/app/site/users/components/user-list/user-list.component.html b/client/src/app/site/users/components/user-list/user-list.component.html index 336da2017..e1179f6b6 100644 --- a/client/src/app/site/users/components/user-list/user-list.component.html +++ b/client/src/app/site/users/components/user-list/user-list.component.html @@ -91,7 +91,7 @@
+
- + @@ -123,44 +131,41 @@ add_circle Set active - - - - - - - - - - + + diff --git a/client/src/app/site/users/components/user-list/user-list.component.ts b/client/src/app/site/users/components/user-list/user-list.component.ts index a0653dc0e..4e5138da1 100644 --- a/client/src/app/site/users/components/user-list/user-list.component.ts +++ b/client/src/app/site/users/components/user-list/user-list.component.ts @@ -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 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 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 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, unset?: boolean): Promise { - 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 { + 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 { - // for (const user of this.selectedRows) { - // await this.resetPassword(user); - // } + } } /** @@ -183,12 +187,20 @@ export class UserListComponent extends ListViewBaseComponent 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 { - // 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 { + for (const user of this.selectedRows) { + const password = this.repo.getRandomPassword(); + this.repo.resetPassword(user, password); + } } public getColumnDefinition(): string[] { diff --git a/client/src/app/site/users/models/view-user.ts b/client/src/app/site/users/models/view-user.ts index 47939abf7..0c330d479 100644 --- a/client/src/app/site/users/models/view-user.ts +++ b/client/src/app/site/users/models/view-user.ts @@ -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; } diff --git a/client/src/app/site/users/services/group-repository.service.ts b/client/src/app/site/users/services/group-repository.service.ts index 9ea9b17a8..a40498a30 100644 --- a/client/src/app/site/users/services/group-repository.service.ts +++ b/client/src/app/site/users/services/group-repository.service.ts @@ -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 diff --git a/client/src/app/site/users/services/user-repository.service.ts b/client/src/app/site/users/services/user-repository.service.ts index 33acc7ec6..9018f08f0 100644 --- a/client/src/app/site/users/services/user-repository.service.ts +++ b/client/src/app/site/users/services/user-repository.service.ts @@ -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 { 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 { 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 { + 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 { + 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; + } }