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

{{ 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..d504e250f --- /dev/null +++ b/client/src/app/shared/components/choice-dialog/choice-dialog.component.scss @@ -0,0 +1,17 @@ +mat-radio-group { + display: inline-flex; + flex-direction: column; + + mat-radio-button { + margin: 5px; + } +} + +.scrollmenu { + padding: 5px; + display: block; +} + +.scrollmenu-outer { + max-height: inherit; +} 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..add33c792 --- /dev/null +++ b/client/src/app/shared/components/choice-dialog/choice-dialog.component.spec.ts @@ -0,0 +1,26 @@ +import { async, TestBed } from '@angular/core/testing'; + +// import { ChoiceDialogComponent } from './choice-dialog.component'; +import { E2EImportsModule } from 'e2e-imports.module'; + +describe('ChoiceDialogComponent', () => { + // let component: ChoiceDialogComponent; + // let fixture: ComponentFixture; + + 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..c728bbb59 --- /dev/null +++ b/client/src/app/shared/components/choice-dialog/choice-dialog.component.ts @@ -0,0 +1,112 @@ +import { Component, Inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material'; +import { Displayable } from 'app/shared/models/base/displayable'; +import { Identifiable } from 'app/shared/models/base/identifiable'; + +/** + * An option needs to be identifiable and should have a strnig to display. Either uses Displayble or + * a label property. + */ +type ChoiceDialogOption = (Identifiable & Displayable) | (Identifiable & { label: string }); + +/** + * All choices in the array should have the same type. + */ +export type ChoiceDialogOptions = (Identifiable & Displayable)[] | (Identifiable & { label: string })[]; + +/** + * All data needed for this dialog + */ +interface ChoiceDialogData { + /** + * A title to display + */ + title: string; + + /** + * The choices to display + */ + choices: ChoiceDialogOptions; + + /** + * Select, if this should be a multiselect choice + */ + multiSelect: boolean; +} + +/** + * undefined is returned, if the dialog is closed. If a choice is submitted, + * it might be a number oder an array of numbers for multiselect. + */ +export type ChoiceAnswer = undefined | number | number[]; + +/** + * A dialog with choice fields. + */ +@Component({ + selector: 'os-choice-dialog', + templateUrl: './choice-dialog.component.html', + styleUrls: ['./choice-dialog.component.scss'] +}) +export class ChoiceDialogComponent { + /** + * One number selected, if this is a single select choice + */ + public selectedChoice: number; + + /** + * All selected ids, if this is a multiselect choice + */ + public selectedMultiChoices: number[] = []; + + public constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: ChoiceDialogData + ) {} + + /** + * Get the title from a choice. Maybe saved in a label property or using getTitle(). + * + * @param choice The choice + * @return the title + */ + public getChoiceTitle(choice: ChoiceDialogOption): string { + if ('label' in choice) { + return choice.label; + } else { + return choice.getTitle(); + } + } + + /** + * Closes the dialog with the selected choices + */ + public closeDialog(ok: boolean): void { + if (ok) { + this.dialogRef.close(this.data.multiSelect ? this.selectedMultiChoices : this.selectedChoice); + } else { + this.dialogRef.close(); + } + } + + /** + * For multiSelect: Determines whether a choice has been activated + * @param choice + */ + public isChosen(choice: Identifiable): boolean { + return this.selectedMultiChoices.indexOf(choice.id) >= 0; + } + + /** + * For multiSelect: Activates/deactivates a multi-Choice option + * @param choice + */ + public toggleChoice(choice: Identifiable): void { + const idx = this.selectedMultiChoices.indexOf(choice.id); + if (idx < 0) { + this.selectedMultiChoices.push(choice.id); + } else { + this.selectedMultiChoices.splice(idx, 1); + } + } +} 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..2c299c05f 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 @@ -1,13 +1,23 @@ - - + +
- - @@ -22,12 +32,12 @@
- -
+ +
-
+
@@ -36,24 +46,31 @@
- - +
- + - diff --git a/client/src/app/shared/models/motions/motion.ts b/client/src/app/shared/models/motions/motion.ts index f44e4523d..a6b06d847 100644 --- a/client/src/app/shared/models/motions/motion.ts +++ b/client/src/app/shared/models/motions/motion.ts @@ -23,7 +23,6 @@ export class Motion extends AgendaBaseModel { public motion_block_id: number; public origin: string; public submitters: MotionSubmitter[]; - public submitters_id: number[]; public supporters_id: number[]; public comments: MotionComment[]; public workflow_id: number; 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..b782337f2 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 @@ -1,32 +1,22 @@ - + -
-

Agenda

-
+

Agenda

- + {{ selectedRows.length }} selected
- +
-
- @@ -52,9 +42,7 @@ Speakers @@ -62,8 +50,11 @@ - + @@ -71,16 +62,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..e0935fd33 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 @@ -1,9 +1,6 @@ - + -
- -

Elections

-
+

Elections

- +
-
- - - + + {{ isSelected(assignment) ? 'check_circle' : '' }} @@ -58,8 +49,11 @@ - + @@ -69,7 +63,7 @@
- + + diff --git a/client/src/app/site/login/components/reset-password-confirm/reset-password-confirm.component.spec.ts b/client/src/app/site/login/components/reset-password-confirm/reset-password-confirm.component.spec.ts index ca00e6c07..78ef346bb 100644 --- a/client/src/app/site/login/components/reset-password-confirm/reset-password-confirm.component.spec.ts +++ b/client/src/app/site/login/components/reset-password-confirm/reset-password-confirm.component.spec.ts @@ -25,7 +25,7 @@ describe('ResetPasswordConfirmComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(ResetPasswordConfirmComponent); component = fixture.componentInstance; - fixture.detectChanges(); + // fixture.detectChanges(); }); it('should create', () => { 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..2308382eb 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 @@ -5,7 +5,6 @@ (mainEvent)="onMainEvent()" (saveEvent)="onSaveEditedFile()" > -

Files

@@ -44,31 +43,18 @@ more_vert
- - -
- -
-
- + {{ selectedRows.length }} selected
- +
- @@ -122,8 +108,11 @@ - + @@ -167,14 +156,21 @@ - - - - + Multiselect + +
+
+ + + +
diff --git a/client/src/app/site/motions/components/amendment-create-wizard/amendment-create-wizard.component.ts b/client/src/app/site/motions/components/amendment-create-wizard/amendment-create-wizard.component.ts index 9161f8713..3482cb1e0 100644 --- a/client/src/app/site/motions/components/amendment-create-wizard/amendment-create-wizard.component.ts +++ b/client/src/app/site/motions/components/amendment-create-wizard/amendment-create-wizard.component.ts @@ -9,8 +9,8 @@ import { TranslateService } from '@ngx-translate/core'; import { MotionRepositoryService } from '../../services/motion-repository.service'; import { ViewMotion } from '../../models/view-motion'; import { LinenumberingService } from '../../services/linenumbering.service'; -import { Motion } from '../../../../shared/models/motions/motion'; import { BaseViewComponent } from '../../../base/base-view'; +import { CreateMotion } from '../../models/create-motion'; /** * Describes the single paragraphs from the base motion. @@ -167,10 +167,10 @@ export class AmendmentCreateWizardComponent extends BaseViewComponent { amendment_paragraphs: amendedParagraphs }; - const fromForm = new Motion(); - fromForm.deserialize(newMotionValues); + const motion = new CreateMotion(); + motion.deserialize(newMotionValues); - const response = await this.repo.create(fromForm); + const response = await this.repo.create(motion); this.router.navigate(['./motions/' + response.id]); } } diff --git a/client/src/app/site/motions/components/motion-detail/motion-detail.component.ts b/client/src/app/site/motions/components/motion-detail/motion-detail.component.ts index bc01dd23a..7f77a963e 100644 --- a/client/src/app/site/motions/components/motion-detail/motion-detail.component.ts +++ b/client/src/app/site/motions/components/motion-detail/motion-detail.component.ts @@ -29,6 +29,8 @@ import { ConfigService } from '../../../../core/services/config.service'; import { Workflow } from 'app/shared/models/motions/workflow'; import { take, takeWhile, multicast, skipWhile } from 'rxjs/operators'; import { LocalPermissionsService } from '../../services/local-permissions.service'; +import { ViewCreateMotion } from '../../models/view-create-motion'; +import { CreateMotion } from '../../models/create-motion'; /** * Component for the motion detail view @@ -260,28 +262,10 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit { } }); // load config variables - this.configService.get('motions_statutes_enabled').subscribe( - (enabled: boolean): void => { - this.statutesEnabled = enabled; - } - ); - this.configService.get('motions_min_supporters').subscribe( - (supporters: number): void => { - this.minSupporters = supporters; - } - ); - - this.configService.get('motions_preamble').subscribe( - (preamble: string): void => { - this.preamble = preamble; - } - ); - - this.configService.get('motions_amendments_enabled').subscribe( - (enabled: boolean): void => { - this.amendmentsEnabled = enabled; - } - ); + this.configService.get('motions_statutes_enabled').subscribe(enabled => (this.statutesEnabled = enabled)); + this.configService.get('motions_min_supporters').subscribe(supporters => (this.minSupporters = supporters)); + this.configService.get('motions_preamble').subscribe(preamble => (this.preamble = preamble)); + this.configService.get('motions_amendments_enabled').subscribe(enabled => (this.amendmentsEnabled = enabled)); } /** @@ -327,8 +311,8 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit { // creates a new motion this.newMotion = true; this.editMotion = true; - this.motion = new ViewMotion(); - this.motionCopy = new ViewMotion(); + this.motion = new ViewCreateMotion(); + this.motionCopy = new ViewCreateMotion(); } else { // load existing motion this.route.params.subscribe(params => { @@ -393,7 +377,7 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit { state_id: [''], recommendation_id: [''], submitters_id: [], - supporters_id: [], + supporters_id: [[]], workflow_id: [], origin: [''] }); @@ -419,45 +403,65 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit { } /** - * Save a motion. Calls the "patchValues" function in the MotionObject - * - * http:post the motion to the server. - * The AutoUpdate-Service should see a change once it arrives and show it - * in the list view automatically + * Before updating or creating, the motions needs to be prepared for paragraph based amendments. + * A motion of type T is created, prepared and deserialized from the given motionValues * + * @param motionValues valus for the new motion + * @param ctor The motion constructor, so different motion types can be created. */ - public async saveMotion(): Promise { - const newMotionValues = { ...this.metaInfoForm.value, ...this.contentForm.value }; - - const fromForm = new Motion(); + private prepareMotionForSave(motionValues: any, ctor: new (...args: any[]) => T): T { + const motion = new ctor(); if (this.motion.isParagraphBasedAmendment()) { - fromForm.amendment_paragraphs = this.motion.amendment_paragraphs.map( - (para: string): string => { - if (para === null) { + motion.amendment_paragraphs = this.motion.amendment_paragraphs.map( + (paragraph: string): string => { + if (paragraph === null) { return null; } else { - return newMotionValues.text; + return motionValues.text; } } ); - newMotionValues.text = ''; + motionValues.text = ''; } - fromForm.deserialize(newMotionValues); + motion.deserialize(motionValues); + return motion; + } + + /** + * Creates a motion. Calls the "patchValues" function in the MotionObject + */ + public async createMotion(): Promise { + const newMotionValues = { ...this.metaInfoForm.value, ...this.contentForm.value }; + const motion = this.prepareMotionForSave(newMotionValues, CreateMotion); try { - if (this.newMotion) { - const response = await this.repo.create(fromForm); - this.router.navigate(['./motions/' + response.id]); - } else { - await this.repo.update(fromForm, this.motionCopy); - // if the motion was successfully updated, change the edit mode. - this.editMotion = false; - } + const response = await this.repo.create(motion); + this.router.navigate(['./motions/' + response.id]); } catch (e) { this.raiseError(e); } } + /** + * Save a motion. Calls the "patchValues" function in the MotionObject + */ + public async updateMotion(): Promise { + const newMotionValues = { ...this.metaInfoForm.value, ...this.contentForm.value }; + const motion = this.prepareMotionForSave(newMotionValues, Motion); + this.repo.update(motion, this.motionCopy).then(() => (this.editMotion = false), this.raiseError); + } + + /** + * In the ui are no distinct buttons for update or create. This is decided here. + */ + public saveMotion(): void { + if (this.newMotion) { + this.createMotion(); + } else { + this.updateMotion(); + } + } + /** * get the formated motion text from the repository. */ 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..4bdf2baf2 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 @@ -1,8 +1,6 @@ - + -
-

Motions

-
+

Motions