Creating multi-paragraph amendments

- new config option to enable/disable multiple paragraphs
This commit is contained in:
Tobias Hößl 2019-04-06 13:24:21 +02:00 committed by Emanuel Schütze
parent 028c358a7f
commit 5978868c37
11 changed files with 264 additions and 99 deletions

View File

@ -120,6 +120,7 @@ _('Amendments');
_('Activate statute amendments');
_('Activate amendments');
_('Show amendments together with motions');
_('Amendments can change multiple paragraphs');
_('Prefix for the identifier for amendments');
_('The title of the motion is always applied.');
_('How to create new amendments');

View File

@ -1,4 +1,5 @@
import { Injectable } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core';
import { Observable } from 'rxjs';
@ -14,7 +15,7 @@ import { DataStoreService } from '../../core-services/data-store.service';
import { DiffLinesInParagraph, DiffService, LineRange, ModificationType } from '../../ui-services/diff.service';
import { HttpService } from 'app/core/core-services/http.service';
import { Item } from 'app/shared/models/agenda/item';
import { LinenumberingService } from '../../ui-services/linenumbering.service';
import { LinenumberingService, LineNumberRange } from '../../ui-services/linenumbering.service';
import { Mediafile } from 'app/shared/models/mediafiles/mediafile';
import { Motion } from 'app/shared/models/motions/motion';
import { MotionBlock } from 'app/shared/models/motions/motion-block';
@ -45,6 +46,36 @@ import { OperatorService } from 'app/core/core-services/operator.service';
type SortProperty = 'callListWeight' | 'identifier';
/**
* Describes the single paragraphs from the base motion.
*/
export interface ParagraphToChoose {
/**
* The paragraph number.
*/
paragraphNo: number;
/**
* The raw HTML of this paragraph.
*/
rawHtml: string;
/**
* The HTML of this paragraph, wrapped in a `SafeHtml`-object.
*/
safeHtml: SafeHtml;
/**
* The first line number
*/
lineFrom: number;
/**
* The last line number
*/
lineTo: number;
}
/**
* Repository Services for motions (and potentially categories)
*
@ -74,6 +105,7 @@ export class MotionRepositoryService extends BaseAgendaContentObjectRepository<V
* @param mapperService Maps collection strings to classes
* @param dataSend sending changed objects
* @param httpService OpenSlides own Http service
* @param sanitizer DOM Sanitizer
* @param lineNumbering Line numbering for motion text
* @param diff Display changes in motion text as diff.
* @param personalNoteService service fo personal notes
@ -87,6 +119,7 @@ export class MotionRepositoryService extends BaseAgendaContentObjectRepository<V
translate: TranslateService,
config: ConfigService,
private httpService: HttpService,
private readonly sanitizer: DomSanitizer,
private readonly lineNumbering: LinenumberingService,
private readonly diff: DiffService,
private treeService: TreeService,
@ -637,6 +670,25 @@ export class MotionRepositoryService extends BaseAgendaContentObjectRepository<V
return this.lineNumbering.splitToParagraphs(html);
}
/**
* Returns the data structure used for creating and editing amendments
*
* @param {ViewMotion} motion
* @param {number} lineLength
*/
public getParagraphsToChoose(motion: ViewMotion, lineLength: number): ParagraphToChoose[] {
return this.getTextParagraphs(motion, true, lineLength).map((paragraph: string, index: number) => {
const affected: LineNumberRange = this.lineNumbering.getLineNumberRange(paragraph);
return {
paragraphNo: index,
safeHtml: this.sanitizer.bypassSecurityTrustHtml(paragraph),
rawHtml: this.lineNumbering.stripLineNumbers(paragraph),
lineFrom: affected.from,
lineTo: affected.to
};
});
}
/**
* Returns all paragraphs that are affected by the given amendment in diff-format
*

View File

@ -21,7 +21,7 @@ interface BreakablePoint {
/**
* An object specifying a range of line numbers.
*/
interface LineNumberRange {
export interface LineNumberRange {
/**
* The first line number to be included.
*/

View File

@ -17,18 +17,24 @@
<form [formGroup]="contentForm" (ngSubmit)="saveAmendment()" class="on-transition-fade">
<mat-horizontal-stepper #matStepper linear>
<mat-step [completed]="contentForm.value.selectedParagraph">
<ng-template matStepLabel>{{ 'Select paragraph' | translate }}</ng-template>
<mat-step [completed]="contentForm.value.selectedParagraphs.length > 0">
<ng-template matStepLabel>{{ 'Select paragraphs' | translate }}</ng-template>
<div>
<section
*ngFor="let paragraph of paragraphs"
class="paragraph-row"
[class.active]="contentForm.value.selectedParagraph === paragraph.paragraphNo"
(click)="selectParagraph(paragraph)"
[class.active]="isParagraphSelected(paragraph)"
(click)="onParagraphClicked(paragraph)"
>
<mat-radio-button
<mat-checkbox
*ngIf="multipleParagraphsAllowed"
class="paragraph-select"
[checked]="contentForm.value.selectedParagraph === paragraph.paragraphNo"
[checked]="isParagraphSelected(paragraph)"
></mat-checkbox>
<mat-radio-button
*ngIf="!multipleParagraphsAllowed"
class="paragraph-select"
[checked]="isParagraphSelected(paragraph)"
></mat-radio-button>
<div class="paragraph-text motion-text" [innerHTML]="paragraph.safeHtml"></div>
</section>
@ -37,28 +43,33 @@
<mat-step>
<ng-template matStepLabel>{{ 'Change paragraph' | translate }}</ng-template>
<h3><span translate>Amendment text</span>&nbsp;<span>*</span></h3>
<!-- Text -->
<h3
[ngClass]="
contentForm.get('text').invalid &&
(contentForm.get('text').dirty || contentForm.get('text').touched)
? 'red-warning-text'
: ''
"
>
<span translate>Amendment text</span>&nbsp;<span>*</span>
</h3>
<editor formControlName="text" [init]="tinyMceSettings" required></editor>
<div
*ngIf="
contentForm.get('text').invalid &&
(contentForm.get('text').dirty || contentForm.get('text').touched)
"
class="red-warning-text"
translate
>
This field is required.
</div>
<section *ngFor="let paragraph of contentForm.value.selectedParagraphs">
<h4 [class.red-warning-text]="contentForm.get('text_' + paragraph.paragraphNo).invalid && (
contentForm.get('text_' + paragraph.paragraphNo).dirty ||
contentForm.get('text_' + paragraph.paragraphNo).touched
)"
>
<span *ngIf="paragraph.lineFrom >= paragraph.lineTo - 1" class="line-number">
{{ 'Line' | translate }} {{ paragraph.lineFrom }}</span>&nbsp;<span>*</span>
<span *ngIf="paragraph.lineFrom < paragraph.lineTo - 1" class="line-number">
{{ 'Line' | translate }} {{ paragraph.lineFrom }}
- {{ paragraph.lineTo - 1 }}</span>&nbsp;<span>*</span>
</h4>
<editor [formControlName]="'text_' + paragraph.paragraphNo" [init]="tinyMceSettings" required></editor>
<div
*ngIf="contentForm.get('text_' + paragraph.paragraphNo).invalid && (
contentForm.get('text_' + paragraph.paragraphNo).dirty ||
contentForm.get('text_' + paragraph.paragraphNo).touched
)"
class="red-warning-text"
translate
>
This field is required.
</div>
</section>
<!-- Reason -->
<h3

View File

@ -1,7 +1,7 @@
import { Component } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { DomSanitizer, SafeHtml, Title } from '@angular/platform-browser';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Title } from '@angular/platform-browser';
import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms';
import { MatSnackBar } from '@angular/material';
import { TranslateService } from '@ngx-translate/core';
@ -9,30 +9,9 @@ import { TranslateService } from '@ngx-translate/core';
import { BaseViewComponent } from 'app/site/base/base-view';
import { ConfigService } from 'app/core/ui-services/config.service';
import { CreateMotion } from 'app/site/motions/models/create-motion';
import { LinenumberingService } from 'app/core/ui-services/linenumbering.service';
import { MotionRepositoryService } from 'app/core/repositories/motions/motion-repository.service';
import { MotionRepositoryService, ParagraphToChoose } from 'app/core/repositories/motions/motion-repository.service';
import { ViewMotion } from 'app/site/motions/models/view-motion';
/**
* Describes the single paragraphs from the base motion.
*/
interface ParagraphToChoose {
/**
* The paragraph number.
*/
paragraphNo: number;
/**
* The raw HTML of this paragraph.
*/
rawHtml: string;
/**
* The HTML of this paragraph, wrapped in a `SafeHtml`-object.
*/
safeHtml: SafeHtml;
}
/**
* The wizard used to create a new amendment based on a motion.
*/
@ -67,6 +46,11 @@ export class AmendmentCreateWizardComponent extends BaseViewComponent {
*/
public reasonRequired: boolean;
/**
* Indicates if an amendment can change multiple paragraphs or only one
*/
public multipleParagraphsAllowed: boolean;
/**
* Constructs this component.
*
@ -77,8 +61,6 @@ export class AmendmentCreateWizardComponent extends BaseViewComponent {
* @param {MotionRepositoryService} repo Motion Repository
* @param {ActivatedRoute} route The activated route
* @param {Router} router The router
* @param {DomSanitizer} sanitizer The DOM Sanitizing library
* @param {LinenumberingService} lineNumbering The line numbering service
* @param {MatSnackBar} matSnackBar Material Design SnackBar
*/
public constructor(
@ -89,8 +71,6 @@ export class AmendmentCreateWizardComponent extends BaseViewComponent {
private repo: MotionRepositoryService,
private route: ActivatedRoute,
private router: Router,
private sanitizer: DomSanitizer,
private lineNumbering: LinenumberingService,
matSnackBar: MatSnackBar
) {
super(titleService, translate, matSnackBar);
@ -104,6 +84,10 @@ export class AmendmentCreateWizardComponent extends BaseViewComponent {
this.configService.get<boolean>('motions_reason_required').subscribe(required => {
this.reasonRequired = required;
});
this.configService.get<boolean>('motions_amendments_multiple_paragraphs').subscribe(allowed => {
this.multipleParagraphsAllowed = allowed;
});
}
/**
@ -114,16 +98,7 @@ export class AmendmentCreateWizardComponent extends BaseViewComponent {
this.route.params.subscribe(params => {
this.repo.getViewModelObservable(params.id).subscribe(newViewMotion => {
this.motion = newViewMotion;
this.paragraphs = this.repo
.getTextParagraphs(this.motion, true, this.lineLength)
.map((paragraph: string, index: number) => {
return {
paragraphNo: index,
safeHtml: this.sanitizer.bypassSecurityTrustHtml(paragraph),
rawHtml: this.lineNumbering.stripLineNumbers(paragraph)
};
});
this.paragraphs = this.repo.getParagraphsToChoose(newViewMotion, this.lineLength);
});
});
}
@ -133,22 +108,85 @@ export class AmendmentCreateWizardComponent extends BaseViewComponent {
*/
public createForm(): void {
this.contentForm = this.formBuilder.group({
selectedParagraph: [null, Validators.required],
text: ['', Validators.required],
selectedParagraphs: [[], Validators.required],
reason: ['', Validators.required]
});
}
public isParagraphSelected(paragraph: ParagraphToChoose): boolean {
return !!this.contentForm.value.selectedParagraphs.find(para => para.paragraphNo === paragraph.paragraphNo);
}
/**
* Called by the template when a paragraph is clicked in single paragraph mode.
* Behaves like a radio-button
*
* @param {ParagraphToChoose} paragraph
*/
public setParagraph(paragraph: ParagraphToChoose): void {
this.contentForm.value.selectedParagraphs.forEach(para => {
this.contentForm.removeControl('text_' + para.paragraphNo);
});
this.contentForm.addControl(
'text_' + paragraph.paragraphNo,
new FormControl(paragraph.rawHtml, Validators.required)
);
this.contentForm.patchValue({
selectedParagraphs: [paragraph]
});
}
/**
* Called by the template when a paragraph is clicked in multiple paragraph mode.
* Behaves like a checkbox
*
* @param {ParagraphToChoose} paragraph
*/
public toggleParagraph(paragraph: ParagraphToChoose): void {
let newParagraphs: ParagraphToChoose[];
const oldSelected: ParagraphToChoose[] = this.contentForm.value.selectedParagraphs;
if (this.isParagraphSelected(paragraph)) {
newParagraphs = oldSelected.filter(para => para.paragraphNo !== paragraph.paragraphNo);
this.contentForm.patchValue({
selectedParagraphs: newParagraphs
});
this.contentForm.removeControl('text_' + paragraph.paragraphNo);
} else {
newParagraphs = Object.assign([], oldSelected);
newParagraphs.push(paragraph);
newParagraphs.sort(
(para1: ParagraphToChoose, para2: ParagraphToChoose): number => {
if (para1.paragraphNo < para2.paragraphNo) {
return -1;
} else if (para1.paragraphNo > para2.paragraphNo) {
return 1;
} else {
return 0;
}
}
);
this.contentForm.addControl(
'text_' + paragraph.paragraphNo,
new FormControl(paragraph.rawHtml, Validators.required)
);
this.contentForm.patchValue({
selectedParagraphs: newParagraphs
});
}
}
/**
* Called by the template when a paragraph is clicked.
*
* @param {ParagraphToChoose} paragraph
*/
public selectParagraph(paragraph: ParagraphToChoose): void {
this.contentForm.patchValue({
selectedParagraph: paragraph.paragraphNo,
text: paragraph.rawHtml
});
public onParagraphClicked(paragraph: ParagraphToChoose): void {
if (this.multipleParagraphsAllowed) {
this.toggleParagraph(paragraph);
} else {
this.setParagraph(paragraph);
}
}
/**
@ -157,10 +195,12 @@ export class AmendmentCreateWizardComponent extends BaseViewComponent {
* @returns {Promise<void>}
*/
public async saveAmendment(): Promise<void> {
let text = '';
const amendedParagraphs = this.paragraphs.map(
(paragraph: ParagraphToChoose, index: number): string => {
if (index === this.contentForm.value.selectedParagraph) {
return this.contentForm.value.text;
if (this.contentForm.value.selectedParagraphs.find(para => para.paragraphNo === index)) {
text = this.contentForm.value['text_' + index];
return this.contentForm.value['text_' + index];
} else {
return null;
}
@ -169,6 +209,7 @@ export class AmendmentCreateWizardComponent extends BaseViewComponent {
const newMotionValues = {
...this.contentForm.value,
title: this.translate.instant('Amendment to') + ' ' + this.motion.identifier,
text: text, // Workaround as 'text' is required from the backend
parent_id: this.motion.id,
category_id: this.motion.category_id,
motion_block_id: this.motion.motion_block_id,

View File

@ -623,17 +623,43 @@
[innerHTML]="getFormattedStatuteAmendment()"
></div>
<!-- The HTML Editor -->
<editor formControlName="text" [init]="tinyMceSettings" *ngIf="motion && editMotion" required></editor>
<div
*ngIf="
<!-- The HTML Editor for motions and traditional amendments -->
<ng-container *ngIf="motion && editMotion && !motion.isParagraphBasedAmendment()">
<editor formControlName="text" [init]="tinyMceSettings" required></editor>
<div
*ngIf="
contentForm.get('text').invalid && (contentForm.get('text').dirty || contentForm.get('text').touched)
"
class="red-warning-text"
translate
>
This field is required.
</div>
class="red-warning-text"
translate
>
This field is required.
</div>
</ng-container>
<!-- The HTML Editor for paragraph-based amendments -->
<ng-container *ngIf="motion && editMotion && motion.isParagraphBasedAmendment()">
<section *ngFor="let paragraph of contentForm.value.selected_paragraphs">
<h4>
<span *ngIf="paragraph.lineFrom >= paragraph.lineTo - 1" class="line-number">
{{ 'Line' | translate }} {{ paragraph.lineFrom }}</span>
<span *ngIf="paragraph.lineFrom < paragraph.lineTo - 1" class="line-number">
{{ 'Line' | translate }} {{ paragraph.lineFrom }} - {{ paragraph.lineTo - 1 }}</span>&nbsp;
<span>*</span>
</h4>
<editor [formControlName]="'text_' + paragraph.paragraphNo" [init]="tinyMceSettings" required></editor>
<div
*ngIf="contentForm.get('text_' + paragraph.paragraphNo).invalid && (
contentForm.get('text_' + paragraph.paragraphNo).dirty ||
contentForm.get('text_' + paragraph.paragraphNo).touched
)"
class="red-warning-text"
translate
>
This field is required.
</div>
</section>
</ng-container>
<!-- Paragraph-based amendments -->
<ng-container *ngIf="!editMotion && motion.isParagraphBasedAmendment()">

View File

@ -25,7 +25,7 @@ import {
MotionChangeRecommendationComponent
} from '../motion-change-recommendation/motion-change-recommendation.component';
import { MotionPdfExportService } from 'app/site/motions/services/motion-pdf-export.service';
import { MotionRepositoryService } from 'app/core/repositories/motions/motion-repository.service';
import { MotionRepositoryService, ParagraphToChoose } 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 { PersonalNoteService } from 'app/core/ui-services/personal-note.service';
@ -643,11 +643,28 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit,
});
if (formMotion.isParagraphBasedAmendment()) {
contentPatch.text = formMotion.amendment_paragraphs.find(
(para: string): boolean => {
return para !== null;
}
);
contentPatch.selected_paragraphs = [];
const parentMotion = this.repo.getViewModel(formMotion.parent_id);
// Hint: lineLength is sometimes not loaded yet when this form is initialized;
// This doesn't hurt as long as patchForm is called when editing mode is started, i.e., later.
if (parentMotion && this.lineLength) {
const paragraphsToChoose = this.repo.getParagraphsToChoose(parentMotion, this.lineLength);
paragraphsToChoose.forEach(
(paragraph: ParagraphToChoose, paragraphNo: number): void => {
if (formMotion.amendment_paragraphs[paragraphNo]) {
this.contentForm.addControl(
'text_' + paragraphNo,
new FormControl('', Validators.required)
);
contentPatch.selected_paragraphs.push(paragraph);
contentPatch.text = formMotion.amendment_paragraphs[paragraphNo]; // Workaround as 'text' is required from the backend
contentPatch['text_' + paragraphNo] = formMotion.amendment_paragraphs[paragraphNo];
}
}
);
}
}
const statuteAmendmentFieldName = 'statute_amendment';
@ -679,6 +696,7 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit,
supporters_id: [[]],
workflow_id: [],
origin: [''],
selected_paragraphs: [],
statute_amendment: [''], // Internal value for the checkbox, not saved to the model
statute_paragraph_id: [''],
motion_block_id: [], // TODO: Can be removed if this is not required
@ -731,11 +749,11 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit,
const motion = new ctor();
if (this.motion.isParagraphBasedAmendment()) {
motion.amendment_paragraphs = this.motion.amendment_paragraphs.map(
(paragraph: string): string => {
(paragraph: string, paragraphNo: number): string => {
if (paragraph === null) {
return null;
} else {
return motionValues.text;
return motionValues['text_' + paragraphNo];
}
}
);

File diff suppressed because one or more lines are too long

View File

@ -1708,8 +1708,8 @@ msgstr "Redner/in auswählen oder suchen ..."
msgid "Select or search new submitter ..."
msgstr "Antragsteller/in auswählen oder suchen ..."
msgid "Select paragraph"
msgstr "Absatz auswählen"
msgid "Select paragraphs"
msgstr "Absätze auswählen"
msgid "Selected values"
msgstr "Ausgewählte Werte"
@ -1786,6 +1786,9 @@ msgstr "Änderungsantrag im Hauptantrag anzeigen"
msgid "Show amendments together with motions"
msgstr "Änderungsanträge zusätzlich in der Hauptantragsübersicht anzeigen"
msgid "Amendments can change multiple paragraphs"
msgstr "Änderungsanträge können mehrere Absätze ändern"
msgid "Show clock"
msgstr "Uhr anzeigen"

View File

@ -1651,7 +1651,7 @@ msgstr ""
msgid "Select or search new submitter ..."
msgstr ""
msgid "Select paragraph"
msgid "Select paragraphs"
msgstr ""
msgid "Selected values"
@ -1726,7 +1726,10 @@ msgstr ""
msgid "Show amendment in parent motoin"
msgstr ""
msgid "Show amendments together with motions"
msgid "Amendments can change multiple paragraphs"
msgstr ""
msgid "Restrict amendments to only one paragraph"
msgstr ""
msgid "Show clock"
@ -2360,4 +2363,4 @@ msgid "undocumented"
msgstr ""
msgid "withdrawed"
msgstr ""
msgstr ""

View File

@ -261,6 +261,16 @@ def get_config_variables():
subgroup="Amendments",
)
yield ConfigVariable(
name="motions_amendments_multiple_paragraphs",
default_value=False,
input_type="boolean",
label="Amendments can change multiple paragraphs",
weight=343,
group="Motions",
subgroup="Amendments",
)
# Supporters
yield ConfigVariable(