From 80cb8051f691e233e0f16ac45c07c72caeda93f7 Mon Sep 17 00:00:00 2001 From: GabrielMeyer Date: Thu, 21 Feb 2019 12:18:10 +0100 Subject: [PATCH] Adds notifications if multiple users want to edit the same motion. Fixes #4217 Adds notifications - Created a class to define notification-objects, which should notify other persons editing the same motion. - Added functions to send notifications, listen to them and unsubscribing them. - Added a warning-function to the `base-view.ts`, which raises the snack bar with the given message and has no duration. Fixes #4217 - Removed unnecessary lines of code. - Fixed merge. Prettified - Added a random number to identify different user. - Now the user can sign in as the same user, but still receive a message if multiple people edit the same motion. Fix the detail of motions - If the user does not click to edit, then the `editNotificationSubscription` was not set. --- client/src/app/site/base/base-view.ts | 26 ++- .../motion-detail/motion-detail.component.ts | 166 ++++++++++++++++-- .../site/motions/models/view-motion-notify.ts | 52 ++++++ 3 files changed, 226 insertions(+), 18 deletions(-) create mode 100644 client/src/app/site/motions/models/view-motion-notify.ts diff --git a/client/src/app/site/base/base-view.ts b/client/src/app/site/base/base-view.ts index 7c17eb5f3..34fefeca0 100644 --- a/client/src/app/site/base/base-view.ts +++ b/client/src/app/site/base/base-view.ts @@ -15,7 +15,7 @@ export abstract class BaseViewComponent extends BaseComponent implements OnDestr /** * A reference to the current error snack bar. */ - private errorSnackBar: MatSnackBarRef; + private messageSnackBar: MatSnackBarRef; /** * Constructor for bas elist views @@ -27,6 +27,14 @@ export abstract class BaseViewComponent extends BaseComponent implements OnDestr super(titleService, translate); } + /** + * Opens the snack bar with the given message. + * This snack bar will only dismiss if the user clicks the 'OK'-button. + */ + protected raiseWarning = (message: string): void => { + this.messageSnackBar = this.matSnackBar.open(message, this.translate.instant('OK')); + }; + /** * Opens an error snack bar with the given error message. * This is implemented as an arrow function to capture the called `this`. You can use this function @@ -34,17 +42,27 @@ export abstract class BaseViewComponent extends BaseComponent implements OnDestr * @param message The message to show. */ protected raiseError = (message: string): void => { - this.errorSnackBar = this.matSnackBar.open(message, this.translate.instant('OK'), { + this.messageSnackBar = this.matSnackBar.open(message, this.translate.instant('OK'), { duration: 0 }); }; + /** + * Function to manually close the snack bar if it will not automatically close + * or it should close in a previous step. + */ + protected closeSnackBar(): void { + if (this.matSnackBar) { + this.matSnackBar.dismiss(); + } + } + /** * automatically dismisses the error snack bar, if the component is destroyed. */ public ngOnDestroy(): void { - if (this.errorSnackBar) { - this.errorSnackBar.dismiss(); + if (this.messageSnackBar) { + this.messageSnackBar.dismiss(); } } } 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 e85b793a5..9ec2182c5 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 @@ -1,5 +1,5 @@ import { ActivatedRoute, Router } from '@angular/router'; -import { Component, OnInit, ElementRef, HostListener, TemplateRef } from '@angular/core'; +import { Component, OnInit, OnDestroy, ElementRef, HostListener, TemplateRef } from '@angular/core'; import { DomSanitizer, SafeHtml, Title } from '@angular/platform-browser'; import { FormBuilder, FormGroup, Validators, FormControl } from '@angular/forms'; import { MatDialog, MatSnackBar, MatCheckboxChange, ErrorStateMatcher } from '@angular/material'; @@ -7,7 +7,6 @@ import { MatDialog, MatSnackBar, MatCheckboxChange, ErrorStateMatcher } from '@a import { BehaviorSubject, Subscription } from 'rxjs'; import { TranslateService } from '@ngx-translate/core'; -import { ItemRepositoryService } from 'app/core/repositories/agenda/item-repository.service'; import { BaseViewComponent } from '../../../base/base-view'; import { CategoryRepositoryService } from 'app/core/repositories/motions/category-repository.service'; import { ChangeRecommendationRepositoryService } from 'app/core/repositories/motions/change-recommendation-repository.service'; @@ -16,7 +15,9 @@ import { CreateMotion } from '../../models/create-motion'; import { ConfigService } from 'app/core/ui-services/config.service'; import { DataStoreService } from 'app/core/core-services/data-store.service'; import { DiffLinesInParagraph, LineRange } from 'app/core/ui-services/diff.service'; +import { ItemRepositoryService } from 'app/core/repositories/agenda/item-repository.service'; import { itemVisibilityChoices, Item } from 'app/shared/models/agenda/item'; +import { LinenumberingService } from 'app/core/ui-services/linenumbering.service'; import { LocalPermissionsService } from '../../services/local-permissions.service'; import { Mediafile } from 'app/shared/models/mediafiles/mediafile'; import { Motion } from 'app/shared/models/motions/motion'; @@ -27,27 +28,30 @@ import { } from '../motion-change-recommendation/motion-change-recommendation.component'; import { MotionPdfExportService } from '../../services/motion-pdf-export.service'; import { MotionRepositoryService } from 'app/core/repositories/motions/motion-repository.service'; +import { NotifyService } from 'app/core/core-services/notify.service'; +import { OperatorService } from 'app/core/core-services/operator.service'; import { PersonalNoteContent } from 'app/shared/models/users/personal-note'; import { PersonalNoteService } from 'app/core/ui-services/personal-note.service'; import { PromptService } from 'app/core/ui-services/prompt.service'; import { StatuteParagraphRepositoryService } from 'app/core/repositories/motions/statute-paragraph-repository.service'; -import { ViewMotionChangeRecommendation } from '../../models/view-change-recommendation'; -import { ViewCreateMotion } from '../../models/view-create-motion'; -import { ViewportService } from 'app/core/ui-services/viewport.service'; -import { ViewUnifiedChange } from '../../../../shared/models/motions/view-unified-change'; -import { ViewStatuteParagraph } from '../../models/view-statute-paragraph'; -import { Workflow } from 'app/shared/models/motions/workflow'; -import { LinenumberingService } from 'app/core/ui-services/linenumbering.service'; import { Tag } from 'app/shared/models/core/tag'; import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service'; import { ViewMotionBlock } from '../../models/view-motion-block'; import { ViewWorkflow, StateCssClassMapping } from '../../models/view-workflow'; import { ViewUser } from 'app/site/users/models/view-user'; import { ViewCategory } from '../../models/view-category'; -import { ViewMediafile } from 'app/site/mediafiles/models/view-mediafile'; +import { ViewCreateMotion } from '../../models/view-create-motion'; import { ViewItem } from 'app/site/agenda/models/view-item'; -import { ViewTag } from 'app/site/tags/models/view-tag'; +import { ViewportService } from 'app/core/ui-services/viewport.service'; +import { ViewMediafile } from 'app/site/mediafiles/models/view-mediafile'; import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service'; +import { ViewMotionChangeRecommendation } from '../../models/view-change-recommendation'; +import { ViewMotionNotificationEditMotion, TypeOfNotificationViewMotion } from '../../models/view-motion-notify'; +import { ViewStatuteParagraph } from '../../models/view-statute-paragraph'; +import { ViewTag } from 'app/site/tags/models/view-tag'; +import { ViewUnifiedChange } from 'app/shared/models/motions/view-unified-change'; + +import { Workflow } from 'app/shared/models/motions/workflow'; /** * Component for the motion detail view @@ -57,7 +61,7 @@ import { ViewModelStoreService } from 'app/core/core-services/view-model-store.s templateUrl: './motion-detail.component.html', styleUrls: ['./motion-detail.component.scss'] }) -export class MotionDetailComponent extends BaseViewComponent implements OnInit { +export class MotionDetailComponent extends BaseViewComponent implements OnInit, OnDestroy { /** * Motion content. Can be a new version */ @@ -320,6 +324,23 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit { */ public newStateExtension = ''; + /** + * Constant to identify the notification-message. + */ + public NOTIFICATION_EDIT_MOTION = 'notifyEditMotion'; + + /** + * Array to recognize, if there are other persons working on the same + * motion and see, if those persons leave the editing-view. + */ + private otherWorkOnMotion: string[] = []; + + /** + * The variable to hold the subscription for notifications in editing-view. + * Necessary to unsubscribe after leaving the editing-view. + */ + private editNotificationSubscription: Subscription; + /** * Constuct the detail view. * @@ -327,7 +348,8 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit { * @param translate * @param matSnackBar * @param vp the viewport service - * @param op Operator Service + * @param operator Operator Service + * @param perms local permissions * @param router to navigate back to the motion list and to an existing motion * @param route determine if this is a new or an existing motion * @param formBuilder For reactive forms. Form Group and Form Control @@ -345,13 +367,17 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit { * @param personalNoteService: personal comments and favorite marker * @param linenumberingService The line numbering service * @param categoryRepo Repository for categories + * @param viewModelStore accessing view models + * @param categoryRepo access the category repository * @param userRepo Repository for users + * @param notifyService: NotifyService work with notification */ public constructor( title: Title, translate: TranslateService, matSnackBar: MatSnackBar, public vp: ViewportService, + private operator: OperatorService, public perms: LocalPermissionsService, private router: Router, private route: ActivatedRoute, @@ -371,7 +397,8 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit { private linenumberingService: LinenumberingService, private viewModelStore: ViewModelStoreService, private categoryRepo: CategoryRepositoryService, - private userRepo: UserRepositoryService + private userRepo: UserRepositoryService, + private notifyService: NotifyService ) { super(title, translate, matSnackBar); @@ -474,6 +501,10 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit { }); } + public ngOnDestroy(): void { + this.unsubscribeEditNotifications(TypeOfNotificationViewMotion.TYPE_CLOSING_EDITING_MOTION); + } + /** * Merges amendments and change recommendations and sorts them by the line numbers. * Called each time one of these arrays changes. @@ -738,6 +769,8 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit { this.createMotion(); } else { this.updateMotionFromForm(); + // When saving the changes, notify other users if they edit the same motion. + this.unsubscribeEditNotifications(TypeOfNotificationViewMotion.TYPE_SAVING_EDITING_MOTION); } } @@ -988,10 +1021,17 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit { if (mode) { this.motionCopy = this.motion.copy(); this.patchForm(this.motionCopy); + this.editNotificationSubscription = this.listenToEditNotification(); + this.sendEditNotification(TypeOfNotificationViewMotion.TYPE_BEGIN_EDITING_MOTION); } if (!mode && this.newMotion) { this.router.navigate(['./motions/']); } + // If the user cancels the work on this motion, + // notify the users who are still editing the same motion + if (!mode && !this.newMotion) { + this.unsubscribeEditNotifications(TypeOfNotificationViewMotion.TYPE_CLOSING_EDITING_MOTION); + } } /** @@ -1250,6 +1290,104 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit { this.repo.followRecommendation(this.motion); } + /** + * Function to send a notification, so that other persons can recognize editing the same motion, if they're doing. + * + * @param type TypeOfNotificationViewMotion defines the type of the notification which is sent. + * @param user Optional userId. If set the function will send a notification to the given userId. + */ + private sendEditNotification(type: TypeOfNotificationViewMotion, user?: number): void { + const content: ViewMotionNotificationEditMotion = { + motionId: this.motion.id, + senderId: this.operator.viewUser.id, + senderName: this.operator.viewUser.short_name, + type: type + }; + if (user) { + this.notifyService.sendToUsers(this.NOTIFICATION_EDIT_MOTION, content, user); + } else { + this.notifyService.sendToAllUsers(this.NOTIFICATION_EDIT_MOTION, content); + } + } + + /** + * Function to listen to notifications if the user edits this motion. + * Handles the notification messages. + * + * @returns A subscription, only if the user wants to edit this motion, to listen to notifications. + */ + private listenToEditNotification(): Subscription { + return this.notifyService.getMessageObservable(this.NOTIFICATION_EDIT_MOTION).subscribe(message => { + const content = message.content; + if (this.operator.viewUser.id !== content.senderId && content.motionId === this.motion.id) { + let warning = ''; + + switch (content.type) { + case TypeOfNotificationViewMotion.TYPE_BEGIN_EDITING_MOTION: + case TypeOfNotificationViewMotion.TYPE_ALSO_EDITING_MOTION: { + if (!this.otherWorkOnMotion.includes(content.senderName)) { + this.otherWorkOnMotion.push(content.senderName); + } + + warning = `${this.translate.instant('Following users are currently editing this motion:')} ${ + this.otherWorkOnMotion + }`; + if (content.type === TypeOfNotificationViewMotion.TYPE_BEGIN_EDITING_MOTION) { + this.sendEditNotification( + TypeOfNotificationViewMotion.TYPE_ALSO_EDITING_MOTION, + message.senderUserId + ); + } + break; + } + case TypeOfNotificationViewMotion.TYPE_CLOSING_EDITING_MOTION: { + this.recognizeOtherWorkerOnMotion(content.senderName); + break; + } + case TypeOfNotificationViewMotion.TYPE_SAVING_EDITING_MOTION: { + warning = `${content.senderName} ${this.translate.instant( + 'has saved his work on this motion.' + )}`; + // Wait, to prevent overlapping snack bars + setTimeout(() => this.recognizeOtherWorkerOnMotion(content.senderName), 2000); + break; + } + } + + if (warning !== '') { + this.raiseWarning(warning); + } + } + }); + } + + /** + * Function to handle leaving persons and + * recognize if there is no other person editing the same motion anymore. + * + * @param senderId The id of the sender who has left the editing-view. + */ + private recognizeOtherWorkerOnMotion(senderName: string): void { + this.otherWorkOnMotion = this.otherWorkOnMotion.filter(value => value !== senderName); + if (this.otherWorkOnMotion.length === 0) { + this.closeSnackBar(); + } + } + + /** + * Function to unsubscribe the notification subscription. + * Before unsubscribing a notification will send with the reason. + * + * @param unsubscriptionReason The reason for the unsubscription. + */ + private unsubscribeEditNotifications(unsubscriptionReason: TypeOfNotificationViewMotion): void { + if (!!this.editNotificationSubscription && !this.editNotificationSubscription.closed) { + this.sendEditNotification(unsubscriptionReason); + this.closeSnackBar(); + this.editNotificationSubscription.unsubscribe(); + } + } + /** * Toggles the favorite status */ diff --git a/client/src/app/site/motions/models/view-motion-notify.ts b/client/src/app/site/motions/models/view-motion-notify.ts new file mode 100644 index 000000000..01ce196be --- /dev/null +++ b/client/src/app/site/motions/models/view-motion-notify.ts @@ -0,0 +1,52 @@ +/** + * Enum to define different types of notifications. + */ +export enum TypeOfNotificationViewMotion { + /** + * Type to declare editing a motion. + */ + TYPE_BEGIN_EDITING_MOTION = 'typeBeginEditingMotion', + + /** + * Type if the edit-view is closing. + */ + TYPE_CLOSING_EDITING_MOTION = 'typeClosingEditingMotion', + + /** + * Type if changes are saved. + */ + TYPE_SAVING_EDITING_MOTION = 'typeSavingEditingMotion', + + /** + * Type to declare if another person is also editing the same motion. + */ + TYPE_ALSO_EDITING_MOTION = 'typeAlsoEditingMotion' +} +/** + * Class to specify the notifications for editing a motion. + */ +export interface ViewMotionNotificationEditMotion { + /** + * The id of the motion the user wants to edit. + * Necessary to identify if users edit the same motion. + */ + motionId: number; + + /** + * The id of the sender. + * Necessary if this differs from senderUserId. + */ + senderId: number; + + /** + * The name of the sender. + * To show the names of the other editors + */ + senderName: string; + + /** + * The type of the notification. + * Separates if the user is beginning the work or closing the edit-view. + */ + type: TypeOfNotificationViewMotion; +}