OpenSlides/client/src/app/site/motions/components/motion-detail/motion-detail.component.ts

698 lines
23 KiB
TypeScript
Raw Normal View History

2018-08-16 17:03:39 +02:00
import { Component, OnInit, ViewChild } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
2018-09-09 18:52:47 +02:00
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { MatDialog, MatExpansionPanel, MatSnackBar, MatCheckboxChange } from '@angular/material';
2018-09-03 14:23:54 +02:00
import { Category } from '../../../../shared/models/motions/category';
import { ViewportService } from '../../../../core/services/viewport.service';
import { MotionRepositoryService } from '../../services/motion-repository.service';
2018-09-30 18:43:20 +02:00
import { ChangeRecoMode, LineNumberingMode, ViewMotion } from '../../models/view-motion';
import { User } from '../../../../shared/models/users/user';
2018-09-13 14:40:04 +02:00
import { DataStoreService } from '../../../../core/services/data-store.service';
2018-09-19 15:18:57 +02:00
import { TranslateService } from '@ngx-translate/core';
import { Motion } from '../../../../shared/models/motions/motion';
import { BehaviorSubject, Subscription, ReplaySubject, concat } from 'rxjs';
2018-09-30 18:43:20 +02:00
import { LineRange } from '../../services/diff.service';
import {
MotionChangeRecommendationComponent,
MotionChangeRecommendationComponentData
} from '../motion-change-recommendation/motion-change-recommendation.component';
import { ChangeRecommendationRepositoryService } from '../../services/change-recommendation-repository.service';
import { ViewChangeReco } from '../../models/view-change-reco';
2018-11-02 11:41:03 +01:00
import { DomSanitizer, SafeHtml, Title } from '@angular/platform-browser';
2018-09-30 18:43:20 +02:00
import { ViewUnifiedChange } from '../../models/view-unified-change';
import { OperatorService } from '../../../../core/services/operator.service';
2018-11-02 11:41:03 +01:00
import { BaseViewComponent } from '../../../base/base-view';
2018-11-20 13:31:56 +01:00
import { ViewStatuteParagraph } from '../../models/view-statute-paragraph';
import { StatuteParagraphRepositoryService } from '../../services/statute-paragraph-repository.service';
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';
/**
* Component for the motion detail view
*/
@Component({
selector: 'os-motion-detail',
templateUrl: './motion-detail.component.html',
styleUrls: ['./motion-detail.component.scss']
})
2018-11-02 11:41:03 +01:00
export class MotionDetailComponent extends BaseViewComponent implements OnInit {
/**
* MatExpansionPanel for the meta info
* Only relevant in mobile view
*/
@ViewChild('metaInfoPanel')
public metaInfoPanel: MatExpansionPanel;
/**
* MatExpansionPanel for the content panel
* Only relevant in mobile view
*/
@ViewChild('contentPanel')
public contentPanel: MatExpansionPanel;
2018-08-16 17:03:39 +02:00
/**
* Motions meta-info
*/
2018-08-29 13:21:25 +02:00
public metaInfoForm: FormGroup;
/**
* Motion content. Can be a new version
*/
2018-08-29 13:21:25 +02:00
public contentForm: FormGroup;
/**
* Determine if the motion is edited
*/
2018-08-29 13:21:25 +02:00
public editMotion = false;
/**
* Determine if the motion is new
*/
2018-08-29 13:21:25 +02:00
public newMotion = false;
2018-08-16 17:03:39 +02:00
/**
* Sets the motions, e.g. via an autoupdate. Reload important things here:
* - Reload the recommendation. Not changed with autoupdates, but if the motion is loaded this needs to run.
*/
public set motion(value: ViewMotion) {
this._motion = value;
this.setupRecommender();
}
/**
* Returns the target motion. Might be the new one or old.
*/
public get motion(): ViewMotion {
return this._motion;
}
/**
* Saves the target motion. Accessed via the getter and setter.
*/
private _motion: ViewMotion;
/**
* Value of the config variable `motions_statutes_enabled` - are statutes enabled?
* @TODO replace by direct access to config variable, once it's available from the templates
*/
public statutesEnabled: boolean;
/**
* Value of the config variable `motions_min_supporters`
*/
public minSupporters: number;
/**
* Value of the config variable `motions_preamble`
*/
public preamble: string;
/**
* Copy of the motion that the user might edit
*/
public motionCopy: ViewMotion;
2018-09-30 18:43:20 +02:00
/**
* All change recommendations to this motion
*/
public changeRecommendations: ViewChangeReco[];
/**
* All change recommendations AND amendments, sorted by line number.
*/
public allChangingObjects: ViewUnifiedChange[];
/**
* Holds all motions. Required to navigate back and forth
*/
public allMotions: ViewMotion[];
/**
* preload the next motion for direct navigation
*/
public nextMotion: ViewMotion;
/**
* preload the previous motion for direct navigation
*/
public previousMotion: ViewMotion;
/**
* statute paragraphs, necessary for amendments
*/
public statuteParagraphs: ViewStatuteParagraph[] = [];
2018-09-19 15:18:57 +02:00
/**
* Subject for the Categories
*/
public categoryObserver: BehaviorSubject<Category[]>;
2018-09-19 15:18:57 +02:00
/**
* Subject for the Categories
*/
public workflowObserver: BehaviorSubject<Workflow[]>;
2018-09-19 15:18:57 +02:00
/**
* Subject for the Submitters
*/
public submitterObserver: BehaviorSubject<User[]>;
2018-09-19 15:18:57 +02:00
/**
* Subject for the Supporters
*/
public supporterObserver: BehaviorSubject<User[]>;
2018-09-19 15:18:57 +02:00
/**
* Determine if the name of supporters are visible
*/
public showSupporters = false;
2018-09-30 18:43:20 +02:00
/**
* Value for os-motion-detail-diff: when this is set, that component scrolls to the given change
*/
public scrollToChange: ViewUnifiedChange = null;
2018-11-05 17:43:44 +01:00
/**
* Custom recommender as set in the settings
*/
public recommender: string;
/**
* The subscription to the recommender config variable.
*/
private recommenderSubscription: Subscription;
2018-08-21 14:56:26 +02:00
/**
* Constuct the detail view.
*
2018-11-02 11:41:03 +01:00
* @param title
* @param translate
* @param matSnackBar
2018-09-03 14:23:54 +02:00
* @param vp the viewport service
* @param op Operator Service
2018-09-03 14:23:54 +02:00
* @param router to navigate back to the motion list and to an existing motion
2018-08-21 14:56:26 +02:00
* @param route determine if this is a new or an existing motion
* @param formBuilder For reactive forms. Form Group and Form Control
2018-09-30 18:43:20 +02:00
* @param dialogService For opening dialogs
2018-11-02 11:41:03 +01:00
* @param repo Motion Repository
* @param changeRecoRepo Change Recommendation Repository
* @param statuteRepo: Statute Paragraph Repository
2018-11-02 11:41:03 +01:00
* @param DS The DataStoreService
* @param configService The configuration provider
2018-11-02 11:41:03 +01:00
* @param sanitizer For making HTML SafeHTML
2018-08-21 14:56:26 +02:00
*/
2018-08-29 13:21:25 +02:00
public constructor(
2018-11-02 11:41:03 +01:00
title: Title,
translate: TranslateService,
matSnackBar: MatSnackBar,
2018-09-03 14:23:54 +02:00
public vp: ViewportService,
public perms: LocalPermissionsService,
private op: OperatorService,
private router: Router,
private route: ActivatedRoute,
private formBuilder: FormBuilder,
2018-09-30 18:43:20 +02:00
private dialogService: MatDialog,
2018-09-13 14:40:04 +02:00
private repo: MotionRepositoryService,
2018-09-30 18:43:20 +02:00
private changeRecoRepo: ChangeRecommendationRepositoryService,
private statuteRepo: StatuteParagraphRepositoryService,
2018-09-19 15:18:57 +02:00
private DS: DataStoreService,
private configService: ConfigService,
2018-11-02 11:41:03 +01:00
private sanitizer: DomSanitizer
) {
2018-11-02 11:41:03 +01:00
super(title, translate, matSnackBar);
2018-08-16 17:03:39 +02:00
this.createForm();
this.getMotionByUrl();
2018-08-21 14:56:26 +02:00
2018-09-19 15:18:57 +02:00
// Initial Filling of the Subjects
this.submitterObserver = new BehaviorSubject(DS.getAll(User));
this.supporterObserver = new BehaviorSubject(DS.getAll(User));
this.categoryObserver = new BehaviorSubject(DS.getAll(Category));
this.workflowObserver = new BehaviorSubject(DS.getAll(Workflow));
2018-09-19 15:18:57 +02:00
// Make sure the subjects are updated, when a new Model for the type arrives
this.DS.changeObservable.subscribe(newModel => {
if (newModel instanceof User) {
this.submitterObserver.next(DS.getAll(User));
this.supporterObserver.next(DS.getAll(User));
} else if (newModel instanceof Category) {
2018-09-19 15:18:57 +02:00
this.categoryObserver.next(DS.getAll(Category));
} else if (newModel instanceof Workflow) {
this.workflowObserver.next(DS.getAll(Workflow));
2018-09-19 15:18:57 +02:00
}
});
// load config variables
2018-11-20 13:31:56 +01:00
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;
}
);
}
2018-09-30 18:43:20 +02:00
/**
* Merges amendments and change recommendations and sorts them by the line numbers.
* Called each time one of these arrays changes.
*/
private recalcUnifiedChanges(): void {
// @TODO implement amendments
this.allChangingObjects = this.changeRecommendations;
this.allChangingObjects.sort((a: ViewUnifiedChange, b: ViewUnifiedChange) => {
if (a.getLineFrom() < b.getLineFrom()) {
return -1;
} else if (a.getLineFrom() > b.getLineFrom()) {
return 1;
} else {
return 0;
}
});
}
/**
* determine the motion to display using the URL
*/
public getMotionByUrl(): void {
if (this.route.snapshot.url[0] && this.route.snapshot.url[0].path === 'new') {
// creates a new motion
this.newMotion = true;
this.editMotion = true;
this.motion = new ViewMotion();
this.motionCopy = new ViewMotion();
} else {
// load existing motion
this.route.params.subscribe(params => {
this.repo.getViewModelObservable(params.id).subscribe(newViewMotion => {
2018-11-05 17:43:44 +01:00
if (newViewMotion) {
this.motion = newViewMotion;
this.patchForm(this.motion);
}
});
this.changeRecoRepo
.getChangeRecosOfMotionObservable(parseInt(params.id, 10))
.subscribe((recos: ViewChangeReco[]) => {
this.changeRecommendations = recos;
this.recalcUnifiedChanges();
});
});
}
}
2018-08-21 14:56:26 +02:00
/**
* Async load the values of the motion in the Form.
*/
public patchForm(formMotion: ViewMotion): void {
const metaInfoPatch = {};
Object.keys(this.metaInfoForm.controls).forEach(ctrl => {
metaInfoPatch[ctrl] = formMotion[ctrl];
});
this.metaInfoForm.patchValue(metaInfoPatch);
2018-11-20 13:31:56 +01:00
const contentPatch: { [key: string]: any } = {};
Object.keys(this.contentForm.controls).forEach(ctrl => {
contentPatch[ctrl] = formMotion[ctrl];
});
const statuteAmendmentFieldName = 'statute_amendment';
contentPatch[statuteAmendmentFieldName] = formMotion.isStatuteAmendment();
this.contentForm.patchValue(contentPatch);
2018-08-16 17:03:39 +02:00
}
2018-08-21 14:56:26 +02:00
/**
* Creates the forms for the Motion and the MotionVersion
*
* TODO: Build a custom form validator
*/
public createForm(): void {
2018-08-16 17:03:39 +02:00
this.metaInfoForm = this.formBuilder.group({
identifier: [''],
category_id: [''],
state_id: [''],
recommendation_id: [''],
2018-09-28 15:10:48 +02:00
submitters_id: [],
supporters_id: [],
workflow_id: [],
origin: ['']
});
this.contentForm = this.formBuilder.group({
title: ['', Validators.required],
text: ['', Validators.required],
reason: [''],
statute_amendment: [''], // Internal value for the checkbox, not saved to the model
statute_paragraph_id: ['']
2018-08-16 17:03:39 +02:00
});
this.updateWorkflowIdForCreateForm();
2018-08-16 17:03:39 +02:00
}
/**
* clicking Shift and Enter will save automatically
*
* @param event has the code
*/
public onKeyDown(event: KeyboardEvent): void {
if (event.key === 'Enter' && event.shiftKey) {
this.saveMotion();
}
}
2018-08-21 14:56:26 +02:00
/**
* 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
2018-08-23 10:35:05 +02:00
*
2018-08-21 14:56:26 +02:00
*/
2018-10-26 11:19:05 +02:00
public async saveMotion(): Promise<void> {
const newMotionValues = { ...this.metaInfoForm.value, ...this.contentForm.value };
const fromForm = new Motion();
fromForm.deserialize(newMotionValues);
2018-11-02 11:41:03 +01:00
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;
}
} catch (e) {
this.raiseError(e);
}
2018-08-16 17:03:39 +02:00
}
2018-09-28 15:10:48 +02:00
/**
* get the formated motion text from the repository.
*/
2018-09-30 18:43:20 +02:00
public getFormattedTextPlain(): string {
// Prevent this.allChangingObjects to be reordered from within formatMotion
const changes: ViewUnifiedChange[] = Object.assign([], this.allChangingObjects);
2018-09-09 18:52:47 +02:00
return this.repo.formatMotion(
this.motion.id,
this.motion.crMode,
2018-09-30 18:43:20 +02:00
changes,
2018-09-09 18:52:47 +02:00
this.motion.lineLength,
this.motion.highlightedLine
);
2018-09-28 15:10:48 +02:00
}
2018-09-30 18:43:20 +02:00
/**
* get the formatted motion text from the repository, as SafeHTML for [innerHTML]
* @returns {SafeHtml}
2018-09-30 18:43:20 +02:00
*/
public getFormattedText(): SafeHtml {
return this.sanitizer.bypassSecurityTrustHtml(this.getFormattedTextPlain());
}
/**
* get the diff html from the statute amendment, as SafeHTML for [innerHTML]
* @returns {SafeHtml}
*/
public getFormattedStatuteAmendment(): SafeHtml {
const diffHtml = this.repo.formatStatuteAmendment(this.statuteParagraphs, this.motion, this.motion.lineLength);
return this.sanitizer.bypassSecurityTrustHtml(diffHtml);
}
2018-08-21 14:56:26 +02:00
/**
* Trigger to delete the motion.
* Sends a delete request over the repository and
* shows a "are you sure" dialog
*/
public async deleteMotionButton(): Promise<void> {
this.repo.delete(this.motion).then(() => {
this.router.navigate(['./motions/']);
}, this.raiseError);
2018-08-22 16:03:49 +02:00
}
2018-09-28 15:10:48 +02:00
/**
* Sets the motions line numbering mode
* @param mode Needs to got the enum defined in ViewMotion
2018-09-28 15:10:48 +02:00
*/
2018-09-30 18:43:20 +02:00
public setLineNumberingMode(mode: LineNumberingMode): void {
2018-09-28 15:10:48 +02:00
this.motion.lnMode = mode;
}
2018-09-09 18:52:47 +02:00
/**
* Returns true if no line numbers are to be shown.
*/
public isLineNumberingNone(): boolean {
2018-09-30 18:43:20 +02:00
return this.motion.lnMode === LineNumberingMode.None;
2018-09-09 18:52:47 +02:00
}
/**
* Returns true if the line numbers are to be shown within the text with no line breaks.
*/
public isLineNumberingInline(): boolean {
2018-09-30 18:43:20 +02:00
return this.motion.lnMode === LineNumberingMode.Inside;
2018-09-09 18:52:47 +02:00
}
/**
* Returns true if the line numbers are to be shown to the left of the text.
*/
public isLineNumberingOutside(): boolean {
2018-09-30 18:43:20 +02:00
return this.motion.lnMode === LineNumberingMode.Outside;
2018-09-09 18:52:47 +02:00
}
2018-09-28 15:10:48 +02:00
/**
* Sets the motions change reco mode
* @param mode Needs to fot to the enum defined in ViewMotion
*/
public setChangeRecoMode(mode: number): void {
this.motion.crMode = mode;
}
2018-09-30 18:43:20 +02:00
/**
* Returns true if the original version (including change recommendation annotation) is to be shown
*/
public isRecoModeOriginal(): boolean {
return this.motion.crMode === ChangeRecoMode.Original;
}
/**
* Returns true if the diff version is to be shown
*/
public isRecoModeDiff(): boolean {
return this.motion.crMode === ChangeRecoMode.Diff;
}
/**
* In the original version, a line number range has been selected in order to create a new change recommendation
*
* @param lineRange
*/
public createChangeRecommendation(lineRange: LineRange): void {
const data: MotionChangeRecommendationComponentData = {
editChangeRecommendation: false,
newChangeRecommendation: true,
lineRange: lineRange,
changeRecommendation: this.repo.createChangeRecommendationTemplate(this.motion.id, lineRange)
};
this.dialogService.open(MotionChangeRecommendationComponent, {
height: '400px',
width: '600px',
data: data
});
}
/**
* In the original version, a change-recommendation-annotation has been clicked
* -> Go to the diff view and scroll to the change recommendation
*/
public gotoChangeRecommendation(changeRecommendation: ViewChangeReco): void {
this.scrollToChange = changeRecommendation;
this.setChangeRecoMode(ChangeRecoMode.Diff);
}
2018-08-21 14:56:26 +02:00
/**
* Comes from the head bar
* @param mode
2018-08-21 14:56:26 +02:00
*/
public setEditMode(mode: boolean): void {
this.editMotion = mode;
if (mode) {
this.motionCopy = this.motion.copy();
this.patchForm(this.motionCopy);
if (this.vp.isMobile) {
this.metaInfoPanel.open();
this.contentPanel.open();
}
}
if (!mode && this.newMotion) {
this.router.navigate(['./motions/']);
}
}
public updateWorkflowIdForCreateForm(): void {
const isStatuteAmendment = !!this.contentForm.get('statute_amendment').value;
const configKey = isStatuteAmendment ? 'motions_statute_amendments_workflow' : 'motions_workflow';
// TODO: This should just be a takeWhile(id => !id), but should include the last one where the id is OK.
// takeWhile will get a inclusive parameter, see https://github.com/ReactiveX/rxjs/pull/4115
this.configService.get<string>(configKey).pipe(multicast(
() => new ReplaySubject(1),
(ids) => ids.pipe(takeWhile(id => !id), o => concat(o, ids.pipe(take(1))))
), skipWhile(id => !id)).subscribe(id => {
this.metaInfoForm.patchValue({
workflow_id: parseInt(id, 10),
});
});
}
/**
* If the checkbox is deactivated, the statute_paragraph_id-field needs to be reset, as only that field is saved
* @param {MatCheckboxChange} $event
*/
public onStatuteAmendmentChange($event: MatCheckboxChange): void {
this.contentForm.patchValue({
statute_paragraph_id: null
});
this.updateWorkflowIdForCreateForm();
}
/**
* The paragraph of the statute to amend was changed -> change the input fields below
* @param {number} newValue
*/
public onStatuteParagraphChange(newValue: number): void {
const selectedParagraph = this.statuteParagraphs.find(par => par.id === newValue);
this.contentForm.patchValue({
title: this.translate.instant('Statute amendment for') + ` ${selectedParagraph.title}`,
text: selectedParagraph.text
});
}
/**
* Navigates the user to the given ViewMotion
* @param motion target
*/
public navigateToMotion(motion: ViewMotion): void {
this.router.navigate(['../' + motion.id], { relativeTo: this.route });
// update the current motion
this.motion = motion;
this.setSurroundingMotions();
}
/**
* Sets the previous and next motion
*/
public setSurroundingMotions(): void {
const indexOfCurrent = this.allMotions.findIndex(motion => {
return motion === this.motion;
});
if (indexOfCurrent > -1) {
if (indexOfCurrent > 0) {
this.previousMotion = this.allMotions[indexOfCurrent - 1];
} else {
this.previousMotion = null;
}
if (indexOfCurrent < this.allMotions.length - 1) {
this.nextMotion = this.allMotions[indexOfCurrent + 1];
} else {
this.nextMotion = null;
}
}
}
2018-11-05 17:43:44 +01:00
/**
* Supports the motion (as requested user)
*/
public support(): void {
this.repo.support(this.motion).then(null, this.raiseError);
}
/**
* Unsupports the motion
*/
public unsupport(): void {
this.repo.unsupport(this.motion).then(null, this.raiseError);
}
/**
* Opens the dialog with all supporters.
* TODO: open dialog here!
*/
public openSupportersDialog(): void {
this.showSupporters = !this.showSupporters;
}
/**
* Sets the state
* @param id Motion state id
*/
public setState(id: number): void {
this.repo.setState(this.motion, id);
}
/**
* Sets the recommendation
* @param id Motion recommendation id
2018-11-05 17:43:44 +01:00
*/
public setRecommendation(id: number): void {
this.repo.setRecommendation(this.motion, id);
2018-11-05 17:43:44 +01:00
}
/**
* Sets the category for current motion
* @param id Motion category id
2018-11-05 17:43:44 +01:00
*/
public setCategory(id: number): void {
this.repo.setCatetory(this.motion, id);
2018-11-05 17:43:44 +01:00
}
/**
* Observes the repository for changes in the motion recommender
*/
public setupRecommender(): void {
const configKey = this.motion.isStatuteAmendment() ? 'motions_statute_recommendations_by' : 'motions_recommendations_by';
if (this.recommenderSubscription) {
this.recommenderSubscription.unsubscribe();
}
this.recommenderSubscription = this.configService.get(configKey).subscribe(recommender => {
this.recommender = recommender;
2018-11-05 17:43:44 +01:00
});
}
/**
* Create the absolute path to the corresponding list of speakers
* @returns the link to the corresponding list of speakers as string
*/
public getSpeakerLink(): string {
return `/agenda/${this.motion.agenda_item_id}/speakers`;
}
/**
* Determine if the user has the correct requirements to alter the motion
*/
public opCanEdit(): boolean {
return this.op.hasPerms('motions.can_manage', 'motions.can_manage_metadata');
}
/**
* Init.
* Sets the surrounding motions to navigate back and forth
*/
public ngOnInit(): void {
this.repo.getViewModelListObservable().subscribe(newMotionList => {
if (newMotionList) {
this.allMotions = newMotionList;
this.setSurroundingMotions();
}
});
this.statuteRepo.getViewModelListObservable().subscribe(newViewStatuteParagraphs => {
this.statuteParagraphs = newViewStatuteParagraphs;
});
}
}