Merge pull request #3975 from CatoTH/OpenSlides-3-Amendments

Amendments
This commit is contained in:
Sean 2018-11-29 14:45:12 +01:00 committed by GitHub
commit ad1fcfdb00
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 1187 additions and 174 deletions

View File

@ -1,6 +1,15 @@
import { Deserializer } from '../base/deserializer';
import { Workflow } from './workflow';
/**
* Specifies if an amendment of this state/recommendation should be merged into the motion
*/
export enum MergeAmendment {
NO = -1,
UNDEFINED = 0,
YES = 1
}
/**
* Representation of a workflow state
*
@ -18,7 +27,8 @@ export class WorkflowState extends Deserializer {
public allow_create_poll: boolean;
public allow_submitter_edit: boolean;
public dont_set_identifier: boolean;
public show_state_extension_field: boolean;
public show_state_extension_field: number;
public merge_amendment_into_final: MergeAmendment;
public show_recommendation_extension_field: boolean;
public next_states_id: number[];
public workflow_id: number;

View File

@ -23,7 +23,8 @@ import {
DateAdapter,
MatIconModule,
MatButtonToggleModule,
MatBadgeModule
MatBadgeModule,
MatStepperModule
} from '@angular/material';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatChipsModule } from '@angular/material';
@ -110,6 +111,7 @@ import { SortingTreeComponent } from './components/sorting-tree/sorting-tree.com
MatIconModule,
MatRadioModule,
MatButtonToggleModule,
MatStepperModule,
DragDropModule,
TranslateModule.forChild(),
RouterModule,
@ -147,6 +149,7 @@ import { SortingTreeComponent } from './components/sorting-tree/sorting-tree.com
MatIconModule,
MatRadioModule,
MatButtonToggleModule,
MatStepperModule,
DragDropModule,
NgxMatSelectSearchModule,
FileDropModule,

View File

@ -0,0 +1,52 @@
<os-head-bar [nav]="false">
<!-- Title -->
<div class="title-slot"><h2 translate>Create amendment</h2></div>
</os-head-bar>
<form [formGroup]="contentForm" (ngSubmit)="saveAmendment()" class="on-transition-fade">
<mat-horizontal-stepper linear>
<mat-step [label]="'Select paragraph' | translate" [completed]="contentForm.value.selectedParagraph">
<ng-template matStepLabel translate>Select paragraph</ng-template>
<div>
<section
*ngFor="let paragraph of paragraphs"
class="paragraph-row"
[class.active]="selectedParagraph === paragraph.paragraphNo"
(click)="selectParagraph(paragraph)"
>
<mat-radio-button
class="paragraph-select"
[checked]="contentForm.value.selectedParagraph === paragraph.paragraphNo"
></mat-radio-button>
<div class="paragraph-text motion-text" [innerHTML]="paragraph.safeHtml"></div>
</section>
</div>
<div>
<button
type="button"
mat-button
matStepperNext
[disabled]="contentForm.value.selectedParagraph === null"
>
<span translate>Next</span>
</button>
</div>
</mat-step>
<mat-step [label]="'Specify the change' | translate">
<ng-template matStepLabel translate>Specify changes</ng-template>
<h5 translate>Amended paragraph</h5>
<!-- The HTML Editor -->
<editor formControlName="text" [init]="tinyMceSettings"></editor>
<h5 translate>Reason</h5>
<!-- The HTML Editor -->
<editor formControlName="reason" [init]="tinyMceSettings"></editor>
<div>
<button type="button" mat-button matStepperPrevious><span translate>Back</span></button>
<button type="button" mat-button (click)="saveAmendment()"><span translate>Create</span></button>
</div>
</mat-step>
</mat-horizontal-stepper>
</form>

View File

@ -0,0 +1,85 @@
.paragraph-row {
display: flex;
flex-direction: row;
cursor: pointer;
padding: 20px 0;
&:hover {
background-color: #eee;
}
&.active {
cursor: default;
background-color: #ccc;
&:hover {
background-color: #ccc;
}
}
.paragraph-select {
flex-basis: 50px;
flex-grow: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.paragraph-text {
flex: 1;
}
}
:host ::ng-deep .motion-text {
p,
ul,
ol,
li,
blockquote {
margin-top: 0;
margin-bottom: 0;
}
li {
padding-bottom: 10px;
}
ol,
ul {
margin-left: 15px;
margin-bottom: 0;
}
padding-left: 40px;
position: relative;
.os-line-number {
display: inline-block;
font-size: 0;
line-height: 0;
width: 22px;
height: 22px;
position: absolute;
left: 0;
padding-right: 55px;
&:after {
content: attr(data-line-number);
position: absolute;
top: 10px;
vertical-align: top;
color: gray;
font-size: 12px;
font-weight: normal;
}
}
}
.wide-form {
textarea {
height: 25vh;
}
::ng-deep {
width: 100%;
}
}

View File

@ -0,0 +1,26 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { AmendmentCreateWizardComponent } from './amendment-create-wizard.component';
import { E2EImportsModule } from '../../../../../e2e-imports.module';
describe('AmendmentCreateWizardComponent', () => {
let component: AmendmentCreateWizardComponent;
let fixture: ComponentFixture<AmendmentCreateWizardComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
declarations: [AmendmentCreateWizardComponent]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(AmendmentCreateWizardComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,176 @@
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 { MatSnackBar } from '@angular/material';
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';
/**
* 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.
*/
@Component({
selector: 'os-amendment-create-wizard',
templateUrl: './amendment-create-wizard.component.html',
styleUrls: ['./amendment-create-wizard.component.scss']
})
export class AmendmentCreateWizardComponent extends BaseViewComponent {
/**
* The motion to be amended
*/
public motion: ViewMotion;
/**
* The paragraphs of the base motion
*/
public paragraphs: ParagraphToChoose[];
/**
* Change recommendation content.
*/
public contentForm: FormGroup;
/**
* Motions meta-info
*/
public metaInfoForm: FormGroup;
/**
* Constructs this component.
*
* @param {Title} titleService set the browser title
* @param {TranslateService} translate the translation service
* @param {FormBuilder} formBuilder Form builder
* @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(
titleService: Title,
translate: TranslateService,
private formBuilder: FormBuilder,
private repo: MotionRepositoryService,
private route: ActivatedRoute,
private router: Router,
private sanitizer: DomSanitizer,
private lineNumbering: LinenumberingService,
matSnackBar: MatSnackBar
) {
super(titleService, translate, matSnackBar);
this.getMotionByUrl();
this.createForm();
}
/**
* determine the motion to display using the URL
*/
public getMotionByUrl(): void {
// load existing motion
this.route.params.subscribe(params => {
this.repo.getViewModelObservable(params.id).subscribe(newViewMotion => {
this.motion = newViewMotion;
this.paragraphs = this.repo
.getTextParagraphs(this.motion, true)
.map((paragraph: string, index: number) => {
return {
paragraphNo: index,
safeHtml: this.sanitizer.bypassSecurityTrustHtml(paragraph),
rawHtml: this.lineNumbering.stripLineNumbers(paragraph)
};
});
});
});
}
/**
* Creates the forms for the Motion and the MotionVersion
*/
public createForm(): void {
this.contentForm = this.formBuilder.group({
selectedParagraph: [null, Validators.required],
text: ['', Validators.required],
reason: ['', Validators.required]
});
this.metaInfoForm = this.formBuilder.group({
identifier: [''],
category_id: [''],
state_id: [''],
recommendation_id: [''],
submitters_id: [],
supporters_id: [],
origin: ['']
});
}
/**
* 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
});
}
/**
* Saves the amendment and navigates to detail view of this amendment
*
* @returns {Promise<void>}
*/
public async saveAmendment(): Promise<void> {
const amendedParagraphs = this.paragraphs.map(
(paragraph: ParagraphToChoose, index: number): string => {
if (index === this.contentForm.value.selectedParagraph) {
return this.contentForm.value.text;
} else {
return null;
}
}
);
const newMotionValues = {
...this.metaInfoForm.value,
...this.contentForm.value,
title: this.translate.instant('Amendment to') + ' ' + this.motion.identifier,
parent_id: this.motion.id,
amendment_paragraphs: amendedParagraphs
};
const fromForm = new Motion();
fromForm.deserialize(newMotionValues);
const response = await this.repo.create(fromForm);
this.router.navigate(['./motions/' + response.id]);
}
}

View File

@ -1,73 +1,78 @@
<!-- A summary of all changes -->
<section class="change-recommendation-overview">
<strong>
{{ 'Summary of changes' | translate }}:
</strong>
<strong> {{ 'Summary of changes' | translate }}: </strong>
<!--
<button os-perms="motions.can_manage" class="btn btn-sm btn-default pull-right"
uib-tooltip="{{ 'Note: You have to reject all change recommendations if the plenum does not follow the recommendation. This does not affect amendments.' | translate }}"
ng-click="viewChangeRecommendations.rejectAllChangeRecommendations(motion)">
<i class="fa fa-thumbs-down"></i>
<translate>Reject all change recommendations</translate>
</button>
<button os-perms="motions.can_manage" class="btn btn-sm btn-default pull-right"
uib-tooltip="{{ 'Note: You have to reject all change recommendations if the plenum does not follow the recommendation. This does not affect amendments.' | translate }}"
ng-click="viewChangeRecommendations.rejectAllChangeRecommendations(motion)">
<i class="fa fa-thumbs-down"></i>
<translate>Reject all change recommendations</translate>
</button>
-->
<ul *ngIf="changes.length > 0">
<li *ngFor="let change of changes">
<a href='' (click)="scrollToChangeClicked(change, $event)"
[class.amendment]="isAmendment(change)"
[class.recommendation]="isChangeRecommendation(change)">
<span *ngIf="change.getLineFrom() >= change.getLineTo() - 1" class="line-number">
{{ 'Line' | translate }} {{ change.getLineFrom() }}<span *ngIf="isChangeRecommendation(change)"></span>
</span>
<span *ngIf="change.getLineFrom() < change.getLineTo() - 1" class="line-number">
{{ 'Line' | translate }} {{ change.getLineFrom() }} -
{{ change.getLineTo() - 1 }}<span *ngIf="isChangeRecommendation(change)"></span>
</span>
<span *ngIf="isChangeRecommendation(change)">({{ 'Change recommendation' | translate }})</span>
<span *ngIf="isAmendment(change)">@TODO Identifier</span>
<span class="operation" *ngIf="isChangeRecommendation(change)">
{{ getRecommendationTypeName(change) }}
<!-- @TODO
<span ng-if="change.original.getType(motion.getVersion(version).text) == 3">
{ change.other_description }
<a
href=""
(click)="scrollToChangeClicked(change, $event)"
[class.amendment]="isAmendment(change)"
[class.recommendation]="isChangeRecommendation(change)"
>
<span *ngIf="change.getLineFrom() >= change.getLineTo() - 1" class="line-number">
{{ 'Line' | translate }} {{ change.getLineFrom() }}
</span>
-->
</span>
<span class="status">
<ng-container *ngIf="change.isRejected()" translate>Rejected</ng-container>
<ng-container *ngIf="change.isAccepted() && isAmendment(change)" translate>Accepted</ng-container>
</span>
</a>
<span *ngIf="change.getLineFrom() < change.getLineTo() - 1" class="line-number">
{{ 'Line' | translate }} {{ change.getLineFrom() }} - {{ change.getLineTo() - 1 }}
</span>
<span *ngIf="isChangeRecommendation(change)"> ({{ 'Change recommendation' | translate }})</span>
<span *ngIf="isAmendment(change)"> ({{ 'Amendment' | translate }} {{ change.getIdentifier() }})</span>
<span class="operation" *ngIf="isChangeRecommendation(change)"
> {{ getRecommendationTypeName(change) }}
<!--
@TODO
<span ng-if="change.original.getType(motion.getVersion(version).text) == 3">
{ change.other_description }
</span>
-->
</span>
<span class="status">
<ng-container *ngIf="change.isRejected()" translate>Rejected</ng-container>
<ng-container *ngIf="change.isAccepted() && isAmendment(change)" translate>Accepted</ng-container>
</span>
</a>
</li>
</ul>
<div *ngIf="changes.length === 0" class="no-changes">
{{ 'No change recommendations yet' | translate }}
</div>
<div *ngIf="changes.length === 0" class="no-changes">{{ 'No change recommendations yet' | translate }}</div>
</section>
<!-- The actual diff view -->
<div class="motion-text-with-diffs">
<div *ngFor="let change of changes; let i = index">
<div class="motion-text line-numbers-outside">
<os-motion-detail-original-change-recommendations
[html]="getTextBetweenChanges(changes[i - 1], change)"
[changeRecommendations]="[]"
(createChangeRecommendation)="onCreateChangeRecommendation($event)"
[html]="getTextBetweenChanges(changes[i - 1], change)"
[changeRecommendations]="[]"
(createChangeRecommendation)="onCreateChangeRecommendation($event)"
></os-motion-detail-original-change-recommendations>
</div>
<div [class.collides]="hasCollissions(change)"
class="diff-box diff-box-{{ change.getChangeId() }} clearfix">
<div class="collission-hint" *ngIf="hasCollissions(change)">
<div
class="diff-box diff-box-{{ change.getChangeId() }} clearfix"
[class.collides]="hasCollissions(change, changes)"
>
<div class="collission-hint" *ngIf="hasCollissions(change, changes)">
<mat-icon matTooltip="{{ 'This change collides with another one.' | translate }}">warning</mat-icon>
</div>
<div class="action-row" *osPerms="'motions.can_manage'">
<button mat-icon-button *ngIf="isRecommendation(change)" type="button"
[matMenuTriggerFor]="changeRecommendationMenu" [matMenuTriggerData]="{change: change}" >
<button
mat-icon-button
*ngIf="isRecommendation(change)"
type="button"
[matMenuTriggerFor]="changeRecommendationMenu"
[matMenuTriggerData]="{ change: change }"
>
<mat-icon>more_vert</mat-icon>
</button>
@ -78,41 +83,53 @@
<i class="fa fa-info"></i>
{{ change.original.identifier }}
</a>
-->
-->
</div>
<div class="status-row" *ngIf="change.isRejected()">
<i class="grey">{{ 'Rejected' | translate }}:</i>
<i class="grey">{{ 'Rejected' | translate }}</i>
</div>
<div class="motion-text motion-text-diff line-numbers-outside"
[attr.data-change-id]="change.getChangeId()"
[innerHTML]="getDiff(change)"></div>
<div
class="motion-text motion-text-diff line-numbers-outside"
[attr.data-change-id]="change.getChangeId()"
[innerHTML]="getDiff(change)"
></div>
</div>
</div>
<div class="motion-text line-numbers-outside">
<os-motion-detail-original-change-recommendations
[html]="getTextRemainderAfterLastChange()"
[changeRecommendations]="[]"
(createChangeRecommendation)="onCreateChangeRecommendation($event)"
[html]="getTextRemainderAfterLastChange()"
[changeRecommendations]="[]"
(createChangeRecommendation)="onCreateChangeRecommendation($event)"
></os-motion-detail-original-change-recommendations>
</div>
</div>
<mat-menu #changeRecommendationMenu="matMenu">
<ng-template matMenuContent let-change="change">
<button type="button" mat-menu-item [disabled]="hasCollissions(change)" (click)="setAcceptanceValue(change, 'accepted')">
<button
type="button"
mat-menu-item
[disabled]="hasCollissions(change)"
(click)="setAcceptanceValue(change, 'accepted')"
>
<mat-icon>thumb_up</mat-icon>
<span translate>Accept</span>
<mat-icon *ngIf="change.isAccepted()" class="active-indicator">done</mat-icon>
</button>
<button type="button" mat-menu-item [disabled]="hasCollissions(change)" (click)="setAcceptanceValue(change, 'rejected')">
<button
type="button"
mat-menu-item
[disabled]="hasCollissions(change)"
(click)="setAcceptanceValue(change, 'rejected')"
>
<mat-icon>thumb_down</mat-icon>
<span translate>Reject</span>
<mat-icon *ngIf="change.isRejected()" class="active-indicator">done</mat-icon>
</button>
<button type="button" mat-menu-item (click)="setInternal(change, !change.internal)">
<mat-icon>{{ change.internal ? "check_box_outline_blank" : "check_box" }}</mat-icon>
<mat-icon>{{ change.internal ? 'check_box_outline_blank' : 'check_box' }}</mat-icon>
<span translate>Public</span>
</button>
<button type="button" mat-menu-item (click)="deleteChangeRecommendation(change, $event)">

View File

@ -54,7 +54,6 @@
border: solid 1px #ddd;
border-radius: 3px;
margin-bottom: 5px;
margin-top: -15px;
padding: 5px 5px 0 5px;
a,

View File

@ -84,11 +84,8 @@ export class MotionDetailDiffComponent extends BaseViewComponent implements Afte
to: change2 ? change2.getLineFrom() : null
};
if (lineRange.from > lineRange.to) {
const msg = 'Inconsistent data.';
return '<em style="color: red; font-weight: bold;">' + msg + '</em>';
}
if (lineRange.from === lineRange.to) {
if (lineRange.from >= lineRange.to) {
// Empty space between two amendments, or between colliding amendments
return '';
}
@ -97,11 +94,21 @@ export class MotionDetailDiffComponent extends BaseViewComponent implements Afte
/**
* Returns true if this change is colliding with another change
* @param change
* @param {ViewUnifiedChange} change
* @param {ViewUnifiedChange[]} changes
*/
public hasCollissions(change: ViewUnifiedChange): boolean {
// @TODO Implementation
return false;
public hasCollissions(change: ViewUnifiedChange, changes: ViewUnifiedChange[]): boolean {
return (
changes.filter((otherChange: ViewUnifiedChange) => {
return (
(otherChange.getChangeId() === change.getChangeId() &&
(otherChange.getLineFrom() >= change.getLineFrom() &&
otherChange.getLineFrom() < change.getLineTo())) ||
(otherChange.getLineTo() > change.getLineFrom() && otherChange.getLineTo() <= change.getLineTo()) ||
(otherChange.getLineFrom() < change.getLineFrom() && otherChange.getLineTo() > change.getLineTo())
);
}).length > 0
);
}
/**

View File

@ -1,21 +1,23 @@
<os-head-bar [mainButton]="opCanEdit()" mainButtonIcon="edit" [nav]="false" [editMode]="editMotion"
(mainEvent)="setEditMode(!editMotion)" (saveEvent)="saveMotion()">
<os-head-bar
[mainButton]="opCanEdit()"
mainButtonIcon="edit"
[nav]="false"
[editMode]="editMotion"
(mainEvent)="setEditMode(!editMotion)"
(saveEvent)="saveMotion()"
>
<!-- Title -->
<div class="title-slot">
<h2 *ngIf="motion && !newMotion">
<span translate>Motion</span>
<!-- Whitespace between "Motion" and identifier -->
<span>&nbsp;</span>
<span *ngIf="!editMotion">{{ motion.identifier }}</span>
<span *ngIf="editMotion">{{ metaInfoForm.get("identifier").value }}</span>
</h2>
<h2 *ngIf="newMotion" translate>
New motion
<span>&nbsp;</span> <span *ngIf="!editMotion">{{ motion.identifier }}</span>
<span *ngIf="editMotion">{{ metaInfoForm.get('identifier').value }}</span>
</h2>
<h2 *ngIf="newMotion" translate>New motion</h2>
</div>
<!-- Back and forth buttons-->
<!-- Back and forth buttons -->
<div *ngIf="!editMotion" class="extra-controls-slot on-transition-fade">
<div *ngIf="previousMotion">
<button mat-button (click)="navigateToMotion(previousMotion)">
@ -56,24 +58,41 @@
<span translate>Project</span>
</button>
<button
mat-menu-item
(click)="createAmendment()"
*ngIf="amendmentsEnabled && motion && !motion.isParagraphBasedAmendment()"
>
<mat-icon>add</mat-icon>
<span translate>Create amendment</span>
</button>
<button
mat-menu-item
(click)="showAmendmentContext = !showAmendmentContext"
*ngIf="motion && motion.isParagraphBasedAmendment()"
>
<mat-icon>{{ !showAmendmentContext ? 'check_box_outline_blank' : 'check_box' }}</mat-icon>
<span translate>Show context</span>
</button>
<mat-divider></mat-divider>
<button mat-menu-item class='red-warning-text' (click)='deleteMotionButton()'>
<button mat-menu-item class="red-warning-text" (click)="deleteMotionButton()">
<mat-icon>delete</mat-icon>
<span translate>Delete</span>
</button>
</div>
</mat-menu>
</os-head-bar>
<!-- Title -->
<div *ngIf="motion" class="motion-title on-transition-fade">
<h2 *ngIf="!editMotion">{{ motion.title }}</h2>
<h2 *ngIf="editMotion">{{ contentForm.get("title").value }}</h2>
<h2 *ngIf="editMotion">{{ contentForm.get('title').value }}</h2>
</div>
<ng-container *ngIf="vp.isMobile; then mobileView; else desktopView"></ng-container>
<ng-container *ngIf="vp.isMobile; then: mobileView; else: desktopView"></ng-container>
<ng-template #mobileView>
<mat-accordion multi='true' class='on-transition-fade'>
@ -94,7 +113,7 @@
</mat-expansion-panel>
<!-- Content -->
<mat-expansion-panel #contentPanel [expanded]='true'>
<mat-expansion-panel #contentPanel [expanded]="true">
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>format_align_left</mat-icon>
@ -115,7 +134,6 @@
<ng-template #desktopView>
<div class="desktop-view">
<div class="desktop-left on-transition-fade">
<!-- Meta Info -->
<div class="meta-info-block meta-info-desktop">
<ng-container *ngTemplateOutlet="metaInfoTemplate"></ng-container>
@ -123,30 +141,30 @@
<os-motion-comments *ngIf="!newMotion" [motion]="motion"></os-motion-comments>
<os-personal-note *ngIf="!newMotion" [motion]="motion"></os-personal-note>
</div>
<div class="desktop-right ">
<!-- Content -->
<mat-card>
<ng-container *ngTemplateOutlet="contentTemplate"></ng-container>
</mat-card>
<mat-card> <ng-container *ngTemplateOutlet="contentTemplate"></ng-container> </mat-card>
</div>
</div>
</ng-template>
<ng-template #metaInfoTemplate>
<form [formGroup]='metaInfoForm' (keydown)="onKeyDown($event)" (ngSubmit)='saveMotion()'>
<form [formGroup]="metaInfoForm" (keydown)="onKeyDown($event)" (ngSubmit)="saveMotion()">
<!-- Identifier -->
<div *ngIf="editMotion && !newMotion">
<!-- <div *ngIf="editMotion"> -->
<div *ngIf='!editMotion'>
<div *ngIf="!editMotion">
<h4 translate>Identifier</h4>
{{ motion.identifier }}
</div>
<mat-form-field *ngIf="editMotion">
<input matInput placeholder='{{ "Identifier" | translate }}' formControlName='identifier' [value]='motionCopy.identifier'>
<input
matInput
placeholder="{{ &quot;Identifier&quot; | translate }}"
formControlName="identifier"
[value]="motionCopy.identifier"
/>
</mat-form-field>
</div>
@ -154,8 +172,14 @@
<div *ngIf="motion && motion.submitters || newMotion">
<div *ngIf="newMotion">
<div *osPerms="['motions.can_manage', 'motions.can_manage_metadata']">
<os-search-value-selector ngDefaultControl [form]="metaInfoForm" [formControl]="metaInfoForm.get('submitters_id')"
[multiple]="true" listname="{{ 'Submitters' | translate }}" [InputListValues]="submitterObserver"></os-search-value-selector>
<os-search-value-selector
ngDefaultControl
[form]="metaInfoForm"
[formControl]="metaInfoForm.get('submitters_id')"
[multiple]="true"
listname="{{ 'Submitters' | translate }}"
[InputListValues]="submitterObserver"
></os-search-value-selector>
</div>
</div>
<div *ngIf="!editMotion && !newMotion">
@ -170,8 +194,14 @@
<div *ngIf='motion && minSupporters'>
<div *ngIf="editMotion">
<div *osPerms="['motions.can_manage', 'motions.can_manage_metadata']">
<os-search-value-selector ngDefaultControl [form]="metaInfoForm" [formControl]="metaInfoForm.get('supporters_id')"
[multiple]="true" listname="{{ 'Supporters' | translate }}" [InputListValues]="supporterObserver"></os-search-value-selector>
<os-search-value-selector
ngDefaultControl
[form]="metaInfoForm"
[formControl]="metaInfoForm.get('supporters_id')"
[multiple]="true"
listname="{{ 'Supporters' | translate }}"
[InputListValues]="supporterObserver"
></os-search-value-selector>
</div>
</div>
<div *ngIf="!editMotion">
@ -259,54 +289,85 @@
<!-- Workflow -->
<div *ngIf="editMotion">
<div *osPerms="['motions.can_manage', 'motions.can_manage_metadata']">
<os-search-value-selector ngDefaultControl [form]="metaInfoForm" [formControl]="metaInfoForm.get('workflow_id')"
[multiple]="false" listname="{{ 'Workflow' | translate }}" [InputListValues]="workflowObserver"></os-search-value-selector>
<os-search-value-selector
ngDefaultControl
[form]="metaInfoForm"
[formControl]="metaInfoForm.get('workflow_id')"
[multiple]="false"
listname="{{ 'Workflow' | translate }}"
[InputListValues]="workflowObserver"
></os-search-value-selector>
</div>
</div>
<!-- Origin -->
<div *ngIf="motion && motion.origin || editMotion">
<div *ngIf='!editMotion'>
<div *ngIf="(motion && motion.origin) || editMotion">
<div *ngIf="!editMotion">
<h4 translate>Origin</h4>
{{ motion.origin }}
</div>
<div *osPerms="['motions.can_manage', 'motions.can_manage_metadata']">
<mat-form-field *ngIf="editMotion">
<input matInput placeholder="{{ 'Origin' | translate}}" formControlName='origin' [value]='motionCopy.origin'>
<input
matInput
placeholder="{{ 'Origin' | translate}}"
formControlName="origin"
[value]="motionCopy.origin"
/>
</mat-form-field>
</div>
</div>
<!-- Voting -->
<!-- <div *ngIf='motion.polls && motion.polls.length > 0 || editMotion'>
<!--
<div *ngIf='motion.polls && motion.polls.length > 0 || editMotion'>
<h4 translate>Voting</h4>
</div> -->
</div>
-->
</form>
</ng-template>
<ng-template #contentTemplate>
<form class="motion-content" [formGroup]='contentForm' (clickdown)="onKeyDown($event)" (keydown)="onKeyDown($event)" (ngSubmit)='saveMotion()'>
<!-- Line Number and Diff buttons-->
<form
class="motion-content"
[formGroup]="contentForm"
(clickdown)="onKeyDown($event)"
(keydown)="onKeyDown($event)"
(ngSubmit)="saveMotion()"
>
<!-- Line Number and Diff buttons -->
<div *ngIf="motion && !editMotion && !motion.isStatuteAmendment()" class="motion-text-controls">
<button type="button" mat-icon-button [matMenuTriggerFor]="lineNumberingMenu" matTooltip="{{ 'Line numbering' | translate }}">
<button
type="button"
mat-icon-button
[matMenuTriggerFor]="lineNumberingMenu"
matTooltip="{{ 'Line numbering' | translate }}"
>
<mat-icon>format_list_numbered</mat-icon>
</button>
<button *ngIf="allChangingObjects.length > 0" type="button" mat-icon-button [matMenuTriggerFor]="changeRecoMenu" matTooltip="{{ 'Change recommendations' | translate }}">
<button
type="button"
mat-icon-button
[matMenuTriggerFor]="changeRecoMenu"
matTooltip="{{ 'Change recommendations' | translate }}"
*ngIf="motion && !motion.isParagraphBasedAmendment() && allChangingObjects.length > 0"
>
<mat-icon>rate_review</mat-icon>
</button>
</div>
<!-- Selecting statute paragraphs for amendment -->
<div class="statute-amendment-selector" *ngIf="editMotion && statuteParagraphs.length > 0 && statutesEnabled">
<mat-checkbox formControlName='statute_amendment' translate (change)="onStatuteAmendmentChange($event)">
<mat-checkbox formControlName="statute_amendment" translate (change)="onStatuteAmendmentChange($event)">
Statute amendment
</mat-checkbox>
<mat-form-field *ngIf="contentForm.value.statute_amendment">
<mat-select [placeholder]="'Select paragraph to amend' | translate"
formControlName='statute_paragraph_id'
(valueChange)="onStatuteParagraphChange($event)">
<mat-select
[placeholder]="'Select paragraph to amend' | translate"
formControlName="statute_paragraph_id"
(valueChange)="onStatuteParagraphChange($event)"
>
<mat-option *ngFor="let paragraph of statuteParagraphs" [value]="paragraph.id">
{{ paragraph.title }}
</mat-option>
@ -315,43 +376,75 @@
</div>
<!-- Title -->
<div *ngIf="motion && editMotion">
<mat-form-field class="wide-form">
<input matInput osAutofocus placeholder="{{ 'Title' | translate }}"
formControlName='title' [value]='motionCopy.title' required>
<div *ngIf="(motion && motion.title) || editMotion">
<div *ngIf="!editMotion">
<h4>{{ motion.title }}</h4>
</div>
<mat-form-field *ngIf="editMotion" class="wide-form">
<input
matInput
osAutofocus
placeholder="{{ 'Title' | translate }}"
formControlName="title"
[value]="motionCopy.title"
required
/>
</mat-form-field>
</div>
<!-- Text -->
<span class="text-prefix-label">{{ preamble | translate }}</span>
<ng-container *ngIf='motion && !editMotion && !motion.isStatuteAmendment()'>
<div *ngIf="!isRecoModeDiff()" class="motion-text" [class.line-numbers-none]="isLineNumberingNone()"
[class.line-numbers-inline]="isLineNumberingInline()" [class.line-numbers-outside]="isLineNumberingOutside()">
<os-motion-detail-original-change-recommendations *ngIf="isLineNumberingOutside() && isRecoModeOriginal()"
[html]="getFormattedTextPlain()" [changeRecommendations]="changeRecommendations"
(createChangeRecommendation)="createChangeRecommendation($event)" (gotoChangeRecommendation)="gotoChangeRecommendation($event)"></os-motion-detail-original-change-recommendations>
<div *ngIf="!isLineNumberingOutside() || !isRecoModeOriginal()" [innerHTML]="getFormattedText()"></div>
<!-- Regular motions or traditional amendments -->
<ng-container
*ngIf="motion && !editMotion && !motion.isStatuteAmendment() && !motion.isParagraphBasedAmendment()"
>
<div
*ngIf="!isRecoModeDiff()"
class="motion-text"
[class.line-numbers-none]="isLineNumberingNone()"
[class.line-numbers-inline]="isLineNumberingInline()"
[class.line-numbers-outside]="isLineNumberingOutside()"
>
<os-motion-detail-original-change-recommendations
*ngIf="isLineNumberingOutside() && isRecoModeOriginal()"
[html]="getFormattedTextPlain()"
[changeRecommendations]="changeRecommendations"
(createChangeRecommendation)="createChangeRecommendation($event)"
(gotoChangeRecommendation)="gotoChangeRecommendation($event)"
></os-motion-detail-original-change-recommendations>
<div
*ngIf="!isLineNumberingOutside() || !isRecoModeOriginal()"
[innerHTML]="sanitizedText(getFormattedTextPlain())"
></div>
</div>
<os-motion-detail-diff *ngIf="isRecoModeDiff()" [motion]="motion" [changes]="allChangingObjects"
[scrollToChange]="scrollToChange" (createChangeRecommendation)="createChangeRecommendation($event)"></os-motion-detail-diff>
<os-motion-detail-diff
*ngIf="isRecoModeDiff()"
[motion]="motion"
[changes]="allChangingObjects"
[scrollToChange]="scrollToChange"
(createChangeRecommendation)="createChangeRecommendation($event)"
></os-motion-detail-diff>
</ng-container>
<div class="motion-text line-numbers-none" *ngIf="motion && !editMotion && motion.isStatuteAmendment()"
[innerHTML]="getFormattedStatuteAmendment()">
</div>
<div
class="motion-text line-numbers-none"
*ngIf="motion && !editMotion && motion.isStatuteAmendment()"
[innerHTML]="getFormattedStatuteAmendment()"
></div>
<!-- The HTML Editor -->
<editor
formControlName='text'
[init]="tinyMceSettings"
*ngIf="motion && editMotion"
></editor>
<editor formControlName="text" [init]="tinyMceSettings" *ngIf="motion && editMotion"></editor>
<!-- Paragraph-based amendments -->
<ng-container *ngIf="motion && !editMotion && motion.isParagraphBasedAmendment()">
<ng-container *ngTemplateOutlet="paragraphBasedAmendment"></ng-container>
</ng-container>
<!-- Reason -->
<div *ngIf="motion || editMotion">
<h5 *ngIf="motion.reason || editMotion" translate>Reason</h5>
<div class="motion-text" *ngIf='!editMotion'>
<div [innerHtml]='motion.reason'></div>
</div>
<div class="motion-text" *ngIf="!editMotion"><div [innerHtml]="motion.reason"></div></div>
<!-- The HTML Editor -->
<editor
@ -360,10 +453,50 @@
*ngIf="editMotion"
></editor>
</div>
</form>
</ng-template>
<ng-template #paragraphBasedAmendment>
<section class="text-holder">
<div class="alert alert-info" *ngIf="this.getAmendedParagraphs().length === 0">
<span translate>No changes at the text.</span>
</div>
<div
*ngFor="let paragraph of this.getAmendedParagraphs()"
class="motion-text motion-text-diff amendment-view"
[class.line-numbers-none]="isLineNumberingNone()"
[class.line-numbers-inline]="isLineNumberingInline()"
[class.line-numbers-outside]="isLineNumberingOutside()"
[class.amendment-context]="showAmendmentContext"
>
<div class="amendment-context" *ngIf="showAmendmentContext">
<div [innerHTML]="getParentMotionRange(1, paragraph.paragraphLineFrom)" class="context"></div>
</div>
<h3
*ngIf="paragraph.diffLineTo === paragraph.diffLineFrom + 1 && !showAmendmentContext"
class="amendment-line-header"
>
<span translate>Line</span> {{ paragraph.diffLineFrom }}:
</h3>
<h3
*ngIf="paragraph.diffLineTo !== paragraph.diffLineFrom + 1 && !showAmendmentContext"
class="amendment-line-header"
>
<span translate>Line</span> {{ paragraph.diffLineFrom }} - {{ paragraph.diffLineTo - 1 }}:
</h3>
<div class="paragraph-context" [innerHtml]="sanitizedText(paragraph.textPre)"></div>
<div [innerHtml]="sanitizedText(paragraph.text)"></div>
<div class="paragraph-context" [innerHtml]="sanitizedText(paragraph.textPost)"></div>
<div class="amendment-context" *ngIf="showAmendmentContext">
<div [innerHtml]="getParentMotionRange(paragraph.paragraphLineTo, null)"></div>
</div>
</div>
</section>
</ng-template>
<!-- Line number Menu -->
<mat-menu #lineNumberingMenu="matMenu">
<div *ngIf="motion">
@ -375,8 +508,8 @@
<!-- Diff View Menu -->
<mat-menu #changeRecoMenu="matMenu">
<button mat-menu-item translate (click)=setChangeRecoMode(0)>Original version</button>
<button mat-menu-item translate (click)=setChangeRecoMode(1)>Changed version</button>
<button mat-menu-item translate (click)=setChangeRecoMode(2)>Diff version</button>
<button mat-menu-item translate (click)=setChangeRecoMode(3)>Final version</button>
<button mat-menu-item translate (click)="setChangeRecoMode(0)">Original version</button>
<button mat-menu-item translate (click)="setChangeRecoMode(1)">Changed version</button>
<button mat-menu-item translate (click)="setChangeRecoMode(2)">Diff version</button>
<button mat-menu-item translate (click)="setChangeRecoMode(3)">Final version</button>
</mat-menu>

View File

@ -268,4 +268,33 @@ span {
display: none;
}
}
.os-split-before {
margin-top: 0;
padding-top: 0;
}
.os-split-after {
margin-bottom: 0;
padding-bottom: 0;
}
li.os-split-before {
list-style: none;
}
}
:host ::ng-deep .amendment-view {
.os-split-after {
margin-bottom: 0;
}
.os-split-before {
margin-top: 0;
}
.paragraph-context {
opacity: 0.5;
}
&.amendment-context .paragraph-context {
opacity: 1;
}
}

View File

@ -12,7 +12,7 @@ import { DataStoreService } from '../../../../core/services/data-store.service';
import { TranslateService } from '@ngx-translate/core';
import { Motion } from '../../../../shared/models/motions/motion';
import { BehaviorSubject, Subscription, ReplaySubject, concat } from 'rxjs';
import { LineRange } from '../../services/diff.service';
import { DiffLinesInParagraph, LineRange } from '../../services/diff.service';
import {
MotionChangeRecommendationComponent,
MotionChangeRecommendationComponentData
@ -110,6 +110,11 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit {
*/
public preamble: string;
/**
* Value of the configuration variable `motions_amendments_enabled` - are amendments enabled?
* @TODO replace by direct access to config variable, once it's available from the templates
*/
public amendmentsEnabled: boolean;
/**
* Copy of the motion that the user might edit
@ -121,6 +126,11 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit {
*/
public changeRecommendations: ViewChangeReco[];
/**
* All amendments to this motions
*/
public amendments: ViewMotion[];
/**
* All change recommendations AND amendments, sorted by line number.
*/
@ -186,6 +196,11 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit {
*/
private recommenderSubscription: Subscription;
/**
* If this is a paragraph-based amendment, this indicates if the non-affected paragraphs should be shown as well
*/
public showAmendmentContext = false;
/**
* Constuct the detail view.
*
@ -255,11 +270,18 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit {
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;
}
);
}
/**
@ -267,8 +289,25 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit {
* Called each time one of these arrays changes.
*/
private recalcUnifiedChanges(): void {
// @TODO implement amendments
this.allChangingObjects = this.changeRecommendations;
this.allChangingObjects = [];
if (this.changeRecommendations) {
this.changeRecommendations.forEach(
(change: ViewUnifiedChange): void => {
this.allChangingObjects.push(change);
}
);
}
if (this.amendments) {
this.amendments.forEach(
(amendment: ViewMotion): void => {
this.repo.getAmendmentAmendedParagraphs(amendment).forEach(
(change: ViewUnifiedChange): void => {
this.allChangingObjects.push(change);
}
);
}
);
}
this.allChangingObjects.sort((a: ViewUnifiedChange, b: ViewUnifiedChange) => {
if (a.getLineFrom() < b.getLineFrom()) {
return -1;
@ -293,18 +332,23 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit {
} else {
// load existing motion
this.route.params.subscribe(params => {
this.repo.getViewModelObservable(params.id).subscribe(newViewMotion => {
const motionId: number = parseInt(params.id, 10);
this.repo.getViewModelObservable(motionId).subscribe(newViewMotion => {
if (newViewMotion) {
this.motion = newViewMotion;
this.patchForm(this.motion);
}
});
this.changeRecoRepo
.getChangeRecosOfMotionObservable(parseInt(params.id, 10))
.subscribe((recos: ViewChangeReco[]) => {
this.changeRecommendations = recos;
this.repo.amendmentsTo(motionId).subscribe(
(amendments: ViewMotion[]): void => {
this.amendments = amendments;
this.recalcUnifiedChanges();
});
}
);
this.changeRecoRepo.getChangeRecosOfMotionObservable(motionId).subscribe((recos: ViewChangeReco[]) => {
this.changeRecommendations = recos;
this.recalcUnifiedChanges();
});
});
}
}
@ -323,6 +367,15 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit {
Object.keys(this.contentForm.controls).forEach(ctrl => {
contentPatch[ctrl] = formMotion[ctrl];
});
if (formMotion.isParagraphBasedAmendment()) {
contentPatch.text = formMotion.amendment_paragraphs.find(
(para: string): boolean => {
return para !== null;
}
);
}
const statuteAmendmentFieldName = 'statute_amendment';
contentPatch[statuteAmendmentFieldName] = formMotion.isStatuteAmendment();
this.contentForm.patchValue(contentPatch);
@ -377,6 +430,18 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit {
const newMotionValues = { ...this.metaInfoForm.value, ...this.contentForm.value };
const fromForm = new Motion();
if (this.motion.isParagraphBasedAmendment()) {
fromForm.amendment_paragraphs = this.motion.amendment_paragraphs.map(
(para: string): string => {
if (para === null) {
return null;
} else {
return newMotionValues.text;
}
}
);
newMotionValues.text = '';
}
fromForm.deserialize(newMotionValues);
try {
@ -409,11 +474,36 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit {
}
/**
* get the formatted motion text from the repository, as SafeHTML for [innerHTML]
* Called from the template to make a HTML string compatible with [innerHTML]
* (otherwise line-number-data-attributes would be stripped out)
*
* @param {string} text
* @returns {SafeHtml}
*/
public getFormattedText(): SafeHtml {
return this.sanitizer.bypassSecurityTrustHtml(this.getFormattedTextPlain());
public sanitizedText(text: string): SafeHtml {
return this.sanitizer.bypassSecurityTrustHtml(text);
}
/**
* If `this.motion` is an amendment, this returns the list of all changed paragraphs.
*
* @returns {DiffLinesInParagraph[]}
*/
public getAmendedParagraphs(): DiffLinesInParagraph[] {
return this.repo.getAmendedParagraphs(this.motion);
}
/**
* If `this.motion` is an amendment, this returns a specified line range from the parent motion
* (e.g. to show the contect in which this amendment is happening)
*
* @param {number} from
* @param {number} to
* @returns {SafeHtml}
*/
public getParentMotionRange(from: number, to: number): SafeHtml {
const str = this.repo.extractMotionLineRange(this.motion.parent_id, { from, to }, true);
return this.sanitizer.bypassSecurityTrustHtml(str);
}
/**
@ -515,6 +605,13 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit {
this.setChangeRecoMode(ChangeRecoMode.Diff);
}
/**
* Goes to the amendment creation wizard. Executed via click.
*/
public createAmendment(): void {
this.router.navigate(['./create-amendment'], { relativeTo: this.route });
}
/**
* Comes from the head bar
* @param mode
@ -539,14 +636,24 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit {
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),
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)
});
});
});
}
/**
@ -655,7 +762,9 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit {
* Observes the repository for changes in the motion recommender
*/
public setupRecommender(): void {
const configKey = this.motion.isStatuteAmendment() ? 'motions_statute_recommendations_by' : 'motions_recommendations_by';
const configKey = this.motion.isStatuteAmendment()
? 'motions_statute_recommendations_by'
: 'motions_recommendations_by';
if (this.recommenderSubscription) {
this.recommenderSubscription.unsubscribe();
}

View File

@ -0,0 +1,80 @@
import { ViewUnifiedChange, ViewUnifiedChangeType } from './view-unified-change';
import { ViewMotion } from './view-motion';
import { LineRange } from '../services/diff.service';
import { MergeAmendment } from '../../../shared/models/motions/workflow-state';
/**
* This represents the Unified Diff part of an amendments.
*
* Hint: As we will probably support multiple affected paragraphs in one amendment in the future,
* Amendments <-> ViewMotionAmendedParagraph is potentially a 1:n-relation
*/
export class ViewMotionAmendedParagraph implements ViewUnifiedChange {
public constructor(
private amendment: ViewMotion,
private paragraphNo: number,
private newText: string,
private lineRange: LineRange
) {}
public getChangeId(): string {
return 'amendment-' + this.amendment.id.toString(10) + '-' + this.paragraphNo.toString(10);
}
public getChangeType(): ViewUnifiedChangeType {
return ViewUnifiedChangeType.TYPE_AMENDMENT;
}
public getLineFrom(): number {
return this.lineRange.from;
}
public getLineTo(): number {
return this.lineRange.to;
}
public getChangeNewText(): string {
return this.newText;
}
/**
* The state and recommendation of this amendment is considered.
* The state takes precedence.
*
* @returns {boolean}
*/
public isAccepted(): boolean {
const mergeState = this.amendment.state
? this.amendment.state.merge_amendment_into_final
: MergeAmendment.UNDEFINED;
switch (mergeState) {
case MergeAmendment.YES:
return true;
case MergeAmendment.NO:
return false;
default:
const mergeRecommendation = this.amendment.recommendation
? this.amendment.recommendation.merge_amendment_into_final
: MergeAmendment.UNDEFINED;
switch (mergeRecommendation) {
case MergeAmendment.YES:
return true;
case MergeAmendment.NO:
return false;
default:
return false;
}
}
}
/**
* @returns {boolean}
*/
public isRejected(): boolean {
return !this.isAccepted();
}
public getIdentifier(): string {
return this.amendment.identifier;
}
}

View File

@ -205,6 +205,14 @@ export class ViewMotion extends BaseViewModel {
return this.item ? this.item.speakerAmount : null;
}
public get parent_id(): number {
return this.motion && this.motion.parent_id ? this.motion.parent_id : null;
}
public get amendment_paragraphs(): string[] {
return this.motion && this.motion.amendment_paragraphs ? this.motion.amendment_paragraphs : [];
}
public constructor(
motion?: Motion,
category?: Category,
@ -340,6 +348,14 @@ export class ViewMotion extends BaseViewModel {
return !!this.statute_paragraph_id;
}
/**
* It's a paragraph-based amendments if only one paragraph is to be changed,
* specified by amendment_paragraphs-array
*/
public isParagraphBasedAmendment(): boolean {
return this.amendment_paragraphs.length > 0;
}
/**
* Duplicate this motion into a copy of itself
*/

View File

@ -7,6 +7,7 @@ import { MotionCommentSectionListComponent } from './components/motion-comment-s
import { StatuteParagraphListComponent } from './components/statute-paragraph-list/statute-paragraph-list.component';
import { SpeakerListComponent } from '../agenda/components/speaker-list/speaker-list.component';
import { CallListComponent } from './components/call-list/call-list.component';
import { AmendmentCreateWizardComponent } from './components/amendment-create-wizard/amendment-create-wizard.component';
const routes: Routes = [
{ path: '', component: MotionListComponent },
@ -16,7 +17,8 @@ const routes: Routes = [
{ path: 'call-list', component: CallListComponent },
{ path: 'new', component: MotionDetailComponent },
{ path: ':id', component: MotionDetailComponent },
{ path: ':id/speakers', component: SpeakerListComponent }
{ path: ':id/speakers', component: SpeakerListComponent },
{ path: ':id/create-amendment', component: AmendmentCreateWizardComponent }
];
@NgModule({

View File

@ -15,6 +15,7 @@ import { MotionCommentsComponent } from './components/motion-comments/motion-com
import { MetaTextBlockComponent } from './components/meta-text-block/meta-text-block.component';
import { PersonalNoteComponent } from './components/personal-note/personal-note.component';
import { CallListComponent } from './components/call-list/call-list.component';
import { AmendmentCreateWizardComponent } from './components/amendment-create-wizard/amendment-create-wizard.component';
@NgModule({
imports: [CommonModule, MotionsRoutingModule, SharedModule],
@ -30,7 +31,8 @@ import { CallListComponent } from './components/call-list/call-list.component';
MotionCommentsComponent,
MetaTextBlockComponent,
PersonalNoteComponent,
CallListComponent
CallListComponent,
AmendmentCreateWizardComponent
],
entryComponents: [
MotionChangeRecommendationComponent,

View File

@ -114,6 +114,44 @@ export interface LineRange {
to: number;
}
/**
* An object representing a paragraph with some changed lines
*/
export interface DiffLinesInParagraph {
/**
* The paragraph number
*/
paragraphNo: number;
/**
* The first line of the paragraph
*/
paragraphLineFrom: number;
/**
* The end line number (after the paragraph)
*/
paragraphLineTo: number;
/**
* The first line number with changes
*/
diffLineFrom: number;
/**
* The line number after the last change
*/
diffLineTo: number;
/**
* The HTML of the not-changed lines before the changed ones
*/
textPre: string;
/**
* The HTML of the changed lines
*/
text: string;
/**
* The HTML of the not-changed lines after the changed ones
*/
textPost: string;
}
/**
* Functionality regarding diffing, merging and extracting line ranges.
*
@ -1024,10 +1062,11 @@ export class DiffService {
return tagStr.replace(
/<(\w+)( [^>]*)?>/gi,
(whole: string, tag: string, tagArguments: string): string => {
tagArguments = (tagArguments ? tagArguments : '');
tagArguments = tagArguments ? tagArguments : '';
if (tagArguments.match(/class="/gi)) {
// class="someclass" => class="someclass insert"
tagArguments = tagArguments.replace(/(class\s*=\s*)(["'])([^\2]*)\2/gi,
tagArguments = tagArguments.replace(
/(class\s*=\s*)(["'])([^\2]*)\2/gi,
(classWhole: string, attr: string, para: string, content: string): string => {
return attr + para + content + ' ' + className + para;
}
@ -1038,7 +1077,7 @@ export class DiffService {
return '<' + tag + tagArguments + '>';
}
);
};
}
/**
* This fixes a very specific, really weird bug that is tested in the test case "does not a change in a very specific case".
@ -1450,6 +1489,18 @@ export class DiffService {
return ret;
}
/**
* Convenience method that takes the html-attribute from an extractRangeByLineNumbers()-method and
* wraps it with the context.
*
* @param {ExtractedContent} diff
*/
public formatDiff(diff: ExtractedContent): string {
return (
diff.outerContextStart + diff.innerContextStart + diff.html + diff.innerContextEnd + diff.outerContextEnd
);
}
/**
* Convenience method that takes the html-attribute from an extractRangeByLineNumbers()-method,
* wraps it with the context and adds line numbers.
@ -1459,8 +1510,7 @@ export class DiffService {
* @param {number} firstLine
*/
public formatDiffWithLineNumbers(diff: ExtractedContent, lineLength: number, firstLine: number): string {
let text =
diff.outerContextStart + diff.innerContextStart + diff.html + diff.innerContextEnd + diff.outerContextEnd;
let text = this.formatDiff(diff);
text = this.lineNumberingService.insertLineNumbers(text, lineLength, null, null, firstLine);
return text;
}
@ -1921,7 +1971,7 @@ export class DiffService {
diffUnnormalized = diffUnnormalized.replace(
/<(ins|del)>([\s\S]*?)<\/\1>/gi,
(whole: string, insDel: string): string => {
const modificationClass = (insDel.toLowerCase() === 'ins' ? 'insert' : 'delete');
const modificationClass = insDel.toLowerCase() === 'ins' ? 'insert' : 'delete';
return whole.replace(
/(<(p|div|blockquote|li)[^>]*>)([\s\S]*?)(<\/\2>)/gi,
(whole2: string, opening: string, blockTag: string, content: string, closing: string): string => {
@ -2017,4 +2067,62 @@ export class DiffService {
return html;
}
/**
* This is used to extract affected lines of a paragraph with the possibility to show the context (lines before
* and after) the changed lines and displaying the line numbers.
*
* @param {number} paragraphNo The paragraph number
* @param {string} origText The original text - needs to be line-numbered
* @param {string} newText The changed text
* @param {number} lineLength the line length
* @return {DiffLinesInParagraph|null}
*/
public getAmendmentParagraphsLinesByMode(
paragraphNo: number,
origText: string,
newText: string,
lineLength: number
): DiffLinesInParagraph {
const paragraph_line_range = this.lineNumberingService.getLineNumberRange(origText),
diff = this.diff(origText, newText),
affected_lines = this.detectAffectedLineRange(diff);
if (affected_lines === null) {
return null;
}
let textPre = '';
let textPost = '';
if (affected_lines.from > paragraph_line_range.from) {
textPre = this.formatDiffWithLineNumbers(
this.extractRangeByLineNumbers(diff, paragraph_line_range.from, affected_lines.from),
lineLength,
paragraph_line_range.from
);
}
if (paragraph_line_range.to > affected_lines.to) {
textPost = this.formatDiffWithLineNumbers(
this.extractRangeByLineNumbers(diff, affected_lines.to, paragraph_line_range.to),
lineLength,
affected_lines.to
);
}
const text = this.formatDiffWithLineNumbers(
this.extractRangeByLineNumbers(diff, affected_lines.from, affected_lines.to),
lineLength,
affected_lines.from
);
return {
paragraphNo: paragraphNo,
paragraphLineFrom: paragraph_line_range.from,
paragraphLineTo: paragraph_line_range.to,
diffLineFrom: affected_lines.from,
diffLineTo: affected_lines.to,
textPre: textPre,
text: text,
textPost: textPost
} as DiffLinesInParagraph;
}
}

View File

@ -1,7 +1,7 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { tap, map } from 'rxjs/operators';
import { DataSendService } from '../../../core/services/data-send.service';
import { Motion } from '../../../shared/models/motions/motion';
@ -13,7 +13,7 @@ import { ChangeRecoMode, ViewMotion } from '../models/view-motion';
import { BaseRepository } from '../../base/base-repository';
import { DataStoreService } from '../../../core/services/data-store.service';
import { LinenumberingService } from './linenumbering.service';
import { DiffService, LineRange, ModificationType } from './diff.service';
import { DiffLinesInParagraph, DiffService, LineRange, ModificationType } from './diff.service';
import { ViewChangeReco } from '../models/view-change-reco';
import { MotionChangeReco } from '../../../shared/models/motions/motion-change-reco';
import { ViewUnifiedChange } from '../models/view-unified-change';
@ -24,6 +24,7 @@ import { HttpService } from 'app/core/services/http.service';
import { Item } from 'app/shared/models/agenda/item';
import { OSTreeSortEvent } from 'app/shared/components/sorting-tree/sorting-tree.component';
import { TreeService } from 'app/core/services/tree.service';
import { ViewMotionAmendedParagraph } from '../models/view-motion-amended-paragraph';
/**
* Repository Services for motions (and potentially categories)
@ -207,6 +208,25 @@ export class MotionRepositoryService extends BaseRepository<ViewMotion, Motion>
await this.httpService.delete(url);
}
/** Returns an observable returning the amendments to a given motion
*
* @param {number} motionId
* @returns {Observable<ViewMotion[]>}
*/
public amendmentsTo(motionId: number): Observable<ViewMotion[]> {
return this.getViewModelListObservable().pipe(
map(
(motions: ViewMotion[]): ViewMotion[] => {
return motions.filter(
(motion: ViewMotion): boolean => {
return motion.parent_id === motionId;
}
);
}
)
);
}
/**
* Format the motion text using the line numbering and change
* reco algorithm.
@ -311,6 +331,7 @@ export class MotionRepositoryService extends BaseRepository<ViewMotion, Motion>
* @param {ViewMotion} motion
* @param {ViewUnifiedChange[]} changes
* @param {number} highlight
* @returns {string}
*/
public getTextRemainderAfterLastChange(
motion: ViewMotion,
@ -381,6 +402,7 @@ export class MotionRepositoryService extends BaseRepository<ViewMotion, Motion>
* @param {ViewMotion} motion
* @param {ViewUnifiedChange} change
* @param {number} highlight
* @returns {string}
*/
public getChangeDiff(motion: ViewMotion, change: ViewUnifiedChange, highlight?: number): string {
const lineLength = motion.lineLength,
@ -427,4 +449,106 @@ export class MotionRepositoryService extends BaseRepository<ViewMotion, Motion>
return diff;
}
/**
* Given an amendment, this returns the motion affected by this amendments
*
* @param {ViewMotion} amendment
* @returns {ViewMotion}
*/
public getAmendmentBaseMotion(amendment: ViewMotion): ViewMotion {
return this.getViewModel(amendment.parent_id);
}
/**
* Splits a motion into paragraphs, optionally adding line numbers
*
* @param {ViewMotion} motion
* @param {boolean} lineBreaks
* @returns {string[]}
*/
public getTextParagraphs(motion: ViewMotion, lineBreaks: boolean): string[] {
if (!motion) {
return [];
}
let html = motion.text;
if (lineBreaks) {
const lineLength = motion.lineLength;
html = this.lineNumbering.insertLineNumbers(html, lineLength);
}
return this.lineNumbering.splitToParagraphs(html);
}
/**
* Returns all paragraphs that are affected by the given amendment in diff-format
*
* @param {ViewMotion} amendment
* @returns {DiffLinesInParagraph}
*/
public getAmendedParagraphs(amendment: ViewMotion): DiffLinesInParagraph[] {
const motion = this.getAmendmentBaseMotion(amendment);
const baseParagraphs = this.getTextParagraphs(motion, true);
const lineLength = amendment.lineLength;
return amendment.amendment_paragraphs
.map(
(newText: string, paraNo: number): DiffLinesInParagraph => {
if (newText === null) {
return null;
}
// Hint: can be either DiffLinesInParagraph or null, if no changes are made
return this.diff.getAmendmentParagraphsLinesByMode(
paraNo,
baseParagraphs[paraNo],
newText,
lineLength
);
}
)
.filter((para: DiffLinesInParagraph) => para !== null);
}
/**
* Returns all paragraphs that are affected by the given amendment as unified change objects.
*
* @param {ViewMotion} amendment
* @returns {ViewMotionAmendedParagraph[]}
*/
public getAmendmentAmendedParagraphs(amendment: ViewMotion): ViewMotionAmendedParagraph[] {
const motion = this.getAmendmentBaseMotion(amendment);
const baseParagraphs = this.getTextParagraphs(motion, true);
const lineLength = amendment.lineLength;
return amendment.amendment_paragraphs
.map(
(newText: string, paraNo: number): ViewMotionAmendedParagraph => {
if (newText === null) {
return null;
}
const origText = baseParagraphs[paraNo],
paragraphLines = this.lineNumbering.getLineNumberRange(origText),
diff = this.diff.diff(origText, newText),
affectedLines = this.diff.detectAffectedLineRange(diff);
if (affectedLines === null) {
return null;
}
let newTextLines = this.lineNumbering.insertLineNumbers(
newText,
lineLength,
null,
null,
paragraphLines.from
);
newTextLines = this.diff.formatDiff(
this.diff.extractRangeByLineNumbers(newTextLines, affectedLines.from, affectedLines.to)
);
return new ViewMotionAmendedParagraph(amendment, paraNo, newTextLines, affectedLines);
}
)
.filter((para: ViewMotionAmendedParagraph) => para !== null);
}
}

View File

@ -0,0 +1,18 @@
# Generated by Django 2.1.2 on 2018-10-29 13:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('motions', '0015_metadata_permission'),
]
operations = [
migrations.AddField(
model_name='state',
name='merge_amendment_into_final',
field=models.SmallIntegerField(default=0),
),
]

View File

@ -1099,6 +1099,20 @@ class State(RESTModelMixin, models.Model):
state name and the entered value of this input field.
"""
merge_amendment_into_final = models.SmallIntegerField(default=0)
"""
Relevant for amendments:
1: Amendments of this statue or recommendation will be merged into the
final version of the motion.
0: Undefined.
-1: Amendments of this status or recommendation will not be merged into the
final version of the motion.
(Hint: The status field takes precedence. That means, if status is 1 or -1,
this is the final decision. The recommendation only is considered if the
status is 0)
"""
show_recommendation_extension_field = models.BooleanField(default=False)
"""
If true, an additional input field (from motion comment) is visible

View File

@ -103,6 +103,7 @@ class StateSerializer(ModelSerializer):
'allow_submitter_edit',
'dont_set_identifier',
'show_state_extension_field',
'merge_amendment_into_final',
'show_recommendation_extension_field',
'next_states',
'workflow')

View File

@ -24,7 +24,8 @@ def create_builtin_workflows(sender, **kwargs):
workflow=workflow_1,
action_word='Accept',
recommendation_label='Acceptance',
css_class='success')
css_class='success',
merge_amendment_into_final=True)
state_1_3 = State.objects.create(name=ugettext_noop('rejected'),
workflow=workflow_1,
action_word='Reject',
@ -55,7 +56,8 @@ def create_builtin_workflows(sender, **kwargs):
workflow=workflow_2,
action_word='Accept',
recommendation_label='Acceptance',
css_class='success')
css_class='success',
merge_amendment_into_final=True)
state_2_4 = State.objects.create(name=ugettext_noop('rejected'),
workflow=workflow_2,
action_word='Reject',