Change recommendations

This commit is contained in:
Tobias Hößl 2018-09-30 18:43:20 +02:00
parent db25ac6bf4
commit 46ad38a98a
No known key found for this signature in database
GPG Key ID: 1D780C7599C2D2A2
27 changed files with 1886 additions and 66 deletions

View File

@ -6,7 +6,7 @@ import { BaseModel } from '../base/base-model';
*/
export class MotionChangeReco extends BaseModel<MotionChangeReco> {
public id: number;
public motion_version_id: number;
public motion_id: number;
public rejected: boolean;
public type: number;
public other_description: string;

View File

@ -20,10 +20,12 @@ import {
MatDatepickerModule,
MatNativeDateModule,
DateAdapter,
MatIconModule
MatIconModule,
MatButtonToggleModule
} from '@angular/material';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatChipsModule } from '@angular/material';
import { MatRadioModule } from '@angular/material';
import { NgxMatSelectSearchModule } from 'ngx-mat-select-search';
import { MatDialogModule } from '@angular/material/dialog';
import { MatListModule } from '@angular/material/list';
@ -88,6 +90,8 @@ import { PromptDialogComponent } from './components/prompt-dialog/prompt-dialog.
// we either wait or include a fixed version manually (dirty)
// https://github.com/google/material-design-icons/issues/786
MatIconModule,
MatRadioModule,
MatButtonToggleModule,
TranslateModule.forChild(),
RouterModule,
NgxMatSelectSearchModule
@ -117,6 +121,8 @@ import { PromptDialogComponent } from './components/prompt-dialog/prompt-dialog.
MatChipsModule,
MatTooltipModule,
MatIconModule,
MatRadioModule,
MatButtonToggleModule,
NgxMatSelectSearchModule,
TranslateModule,
PermsDirective,

View File

@ -0,0 +1,19 @@
<h2 mat-dialog-title>Create a change recommendation</h2>
<mat-dialog-content>
<form class="motion-content" [formGroup]='contentForm' (ngSubmit)='saveChangeRecommendation()'>
<h3>Change from line {{ lineRange.from }} to {{ lineRange.to }}:</h3>
<mat-radio-group #rGroup formControlName="diffType">
<mat-radio-button *ngFor="let radio of replacementTypes" [value]="radio.value">{{ radio.title }}</mat-radio-button>
</mat-radio-group>
<mat-form-field class="wide-form">
<textarea matInput placeholder='Change recommendation Text' formControlName='text'></textarea>
</mat-form-field>
</form>
</mat-dialog-content>
<mat-dialog-actions>
<button mat-button mat-dialog-close>Abort</button>
<!-- The mat-dialog-close directive optionally accepts a value as a result for the dialog. -->
<button mat-button (click)="saveChangeRecommendation()">save</button>
</mat-dialog-actions>

View File

@ -0,0 +1,9 @@
.wide-form {
textarea {
height: 100px;
}
::ng-deep {
width: 100%;
}
}

View File

@ -0,0 +1,54 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import {
MotionChangeRecommendationComponent,
MotionChangeRecommendationComponentData
} from './motion-change-recommendation.component';
import { E2EImportsModule } from '../../../../../e2e-imports.module';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material';
import { ModificationType } from '../../services/diff.service';
import { ViewChangeReco } from '../../models/view-change-reco';
describe('MotionChangeRecommendationComponent', () => {
let component: MotionChangeRecommendationComponent;
let fixture: ComponentFixture<MotionChangeRecommendationComponent>;
const changeReco = <ViewChangeReco>{
line_from: 1,
line_to: 2,
type: ModificationType.TYPE_REPLACEMENT,
text: '<p>',
rejected: false,
motion_id: 1
};
const dialogData: MotionChangeRecommendationComponentData = {
newChangeRecommendation: true,
editChangeRecommendation: false,
changeRecommendation: changeReco,
lineRange: { from: 1, to: 2 }
};
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
declarations: [MotionChangeRecommendationComponent],
providers: [
{ provide: MatDialogRef, useValue: {} },
{
provide: MAT_DIALOG_DATA,
useValue: dialogData
}
]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(MotionChangeRecommendationComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,135 @@
import { Component, Inject } from '@angular/core';
import { LineRange, ModificationType } from '../../services/diff.service';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ChangeRecommendationRepositoryService } from '../../services/change-recommendation-repository.service';
import { ViewChangeReco } from '../../models/view-change-reco';
/**
* Data that needs to be provided to the MotionChangeRecommendationComponent dialog
*/
export interface MotionChangeRecommendationComponentData {
editChangeRecommendation: boolean;
newChangeRecommendation: boolean;
lineRange: LineRange;
changeRecommendation: ViewChangeReco;
}
/**
* The dialog for creating and editing change recommendations from within the os-motion-detail-component.
*
* @example
* ```ts
* const data: MotionChangeRecommendationComponentData = {
* editChangeRecommendation: false,
* newChangeRecommendation: true,
* lineRange: lineRange,
* motion: this.motion,
* };
* this.dialogService.open(MotionChangeRecommendationComponent, {
* height: '400px',
* width: '600px',
* data: data,
* });
* ```
*
*/
@Component({
selector: 'os-motion-change-recommendation',
templateUrl: './motion-change-recommendation.component.html',
styleUrls: ['./motion-change-recommendation.component.scss']
})
export class MotionChangeRecommendationComponent {
/**
* Determine if the change recommendation is edited
*/
public editReco = false;
/**
* Determine if the change recommendation is new
*/
public newReco = false;
/**
* The change recommendation
*/
public changeReco: ViewChangeReco;
/**
* The line range affected by this change recommendation
*/
public lineRange: LineRange;
/**
* Change recommendation content.
*/
public contentForm: FormGroup;
/**
* The replacement types for the radio group
* @TODO translate
*/
public replacementTypes = [
{
value: ModificationType.TYPE_REPLACEMENT,
title: 'Replacement'
},
{
value: ModificationType.TYPE_INSERTION,
title: 'Insertion'
},
{
value: ModificationType.TYPE_DELETION,
title: 'Deletion'
}
];
public constructor(
@Inject(MAT_DIALOG_DATA) public data: MotionChangeRecommendationComponentData,
private formBuilder: FormBuilder,
private repo: ChangeRecommendationRepositoryService,
private dialogRef: MatDialogRef<MotionChangeRecommendationComponent>
) {
this.editReco = data.editChangeRecommendation;
this.newReco = data.newChangeRecommendation;
this.changeReco = data.changeRecommendation;
this.lineRange = data.lineRange;
this.createForm();
}
/**
* Creates the forms for the Motion and the MotionVersion
*/
public createForm(): void {
this.contentForm = this.formBuilder.group({
text: [this.changeReco.text, Validators.required],
diffType: [this.changeReco.type, Validators.required]
});
}
public saveChangeRecommendation(): void {
this.changeReco.updateChangeReco(
this.contentForm.controls.diffType.value,
this.contentForm.controls.text.value
);
if (this.newReco) {
this.repo.createByViewModel(this.changeReco).subscribe(response => {
if (response.id) {
this.dialogRef.close(response);
} else {
// @TODO Show an error message
}
});
} else {
this.repo.update(this.changeReco.changeRecommendation, this.changeReco).subscribe(response => {
if (response.id) {
this.dialogRef.close(response);
} else {
// @TODO Show an error message
}
});
}
}
}

View File

@ -0,0 +1,123 @@
<!-- A summary of all changes -->
<section class="change-recommendation-overview">
<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>
-->
<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 }
</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>
</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)"
></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)">
<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}" >
<mat-icon>more_vert</mat-icon>
</button>
<!--
<a ng-if="change.type == 'amendment'" ui-sref="motions.motion.detail({id: change.original.id})"
uib-tooltip="Open amendment"
class="btn btn-default btn-sm pull-right btn-amend-info">
<i class="fa fa-info"></i>
{{ change.original.identifier }}
</a>
-->
</div>
<div class="status-row" *ngIf="change.isRejected()">
<i class="grey" translate>{{ '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>
</div>
<div class="motion-text line-numbers-outside">
<os-motion-detail-original-change-recommendations
[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')">
<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')">
<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)="deleteChangeRecommendation(change, $event)">
<mat-icon>delete</mat-icon>
<span translate>Delete</span>
</button>
<button type="button" mat-menu-item (click)="editChangeRecommendation(change, $event)">
<mat-icon>edit</mat-icon>
<span translate>Edit</span>
</button>
</ng-template>
</mat-menu>

View File

@ -0,0 +1,116 @@
/* Diffbox */
.diff-box {
background-color: #f9f9f9;
border: solid 1px #eee;
border-radius: 3px;
margin-bottom: 0;
padding-top: 0;
padding-right: 155px;
&:hover {
background-color: #f0f0f0;
.action-row {
opacity: 1;
}
}
.action-row {
font-size: 0.8em;
padding-top: 5px;
padding-bottom: 5px;
float: right;
width: 150px;
text-align: right;
margin-right: -150px;
opacity: 0.5;
.btn-delete {
margin-left: 5px;
color: red;
}
.btn-edit {
margin-left: 5px;
}
.btn-amend-info {
margin-left: 5px;
min-width: 68px;
}
}
.status-row {
font-style: italic;
color: gray;
& > *:after {
content: ':';
}
}
}
.change-recommendation-overview {
background-color: #eee;
border: solid 1px #ddd;
border-radius: 3px;
margin-bottom: 5px;
margin-top: -15px;
padding: 5px 5px 0 5px;
a,
a:link,
a:active {
text-decoration: none;
}
h3 {
margin-top: 10px;
}
ul {
list-style: none;
display: table;
}
li {
display: table-row;
cursor: pointer;
&:hover {
text-decoration: underline;
}
& > * {
display: table-cell;
padding: 1px;
}
}
.status {
color: gray;
font-style: italic;
& > *:before {
content: '(';
}
& > *:after {
content: ')';
}
}
.no-changes {
font-style: italic;
color: grey;
}
}
::ng-deep .mat-menu-content {
.active-indicator {
font-size: 18px;
color: green !important;
margin-left: 10px;
margin-right: 0;
margin-top: 3px;
}
}

View File

@ -0,0 +1,58 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { E2EImportsModule } from '../../../../../e2e-imports.module';
import { Component } from '@angular/core';
import { ViewMotion } from '../../models/view-motion';
import { ViewChangeReco } from '../../models/view-change-reco';
import { MotionDetailDiffComponent } from './motion-detail-diff.component';
import { MotionDetailOriginalChangeRecommendationsComponent } from '../motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component';
@Component({
template: `
<os-motion-detail-diff
[motion]="motion"
[changes]="changes"
(scrollToChange)="scrollToChange($event)"
(createChangeRecommendation)="createChangeRecommendation($event)"
>
</os-motion-detail-diff>`
})
class TestHostComponent {
public motion: ViewMotion;
public changes: ViewChangeReco[];
public constructor() {
this.motion = new ViewMotion();
this.changes = [];
}
public scrollToChange($event: Event): void {}
public createChangeRecommendation($event: Event): void {}
}
describe('MotionDetailDiffComponent', () => {
let component: TestHostComponent;
let fixture: ComponentFixture<TestHostComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
declarations: [
TestHostComponent,
MotionDetailDiffComponent,
MotionDetailOriginalChangeRecommendationsComponent
]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(TestHostComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,253 @@
import { AfterViewInit, Component, ElementRef, EventEmitter, Input, Output } from '@angular/core';
import { ViewMotion } from '../../models/view-motion';
import { ViewUnifiedChange, ViewUnifiedChangeType } from '../../models/view-unified-change';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { MotionRepositoryService } from '../../services/motion-repository.service';
import { LineRange, ModificationType } from '../../services/diff.service';
import { ViewChangeReco } from '../../models/view-change-reco';
import { MatDialog } from '@angular/material';
import { ChangeRecommendationRepositoryService } from '../../services/change-recommendation-repository.service';
import {
MotionChangeRecommendationComponent,
MotionChangeRecommendationComponentData
} from '../motion-change-recommendation/motion-change-recommendation.component';
/**
* This component displays the original motion text with the change blocks inside.
* If the user is an administrator, each change block can be rejected.
*
* The line numbers are provided within the pre-rendered HTML, so we have to work with raw HTML and native HTML elements.
*
* It takes the styling from the parent component.
*
* ## Examples
*
* ```html
* <os-motion-detail-diff
* [motion]="motion"
* [changes]="changes"
* [scrollToChange]="change"
* (createChangeRecommendation)="createChangeRecommendation($event)"
* ></os-motion-detail-diff>
* ```
*/
@Component({
selector: 'os-motion-detail-diff',
templateUrl: './motion-detail-diff.component.html',
styleUrls: ['./motion-detail-diff.component.scss']
})
export class MotionDetailDiffComponent implements AfterViewInit {
@Input()
public motion: ViewMotion;
@Input()
public changes: ViewUnifiedChange[];
@Input()
public scrollToChange: ViewUnifiedChange;
@Output()
public createChangeRecommendation: EventEmitter<LineRange> = new EventEmitter<LineRange>();
public constructor(
private sanitizer: DomSanitizer,
private motionRepo: MotionRepositoryService,
private recoRepo: ChangeRecommendationRepositoryService,
private dialogService: MatDialog,
private el: ElementRef
) {}
/**
* Returns the part of this motion between two change objects
* @param {ViewUnifiedChange} change1
* @param {ViewUnifiedChange} change2
*/
public getTextBetweenChanges(change1: ViewUnifiedChange, change2: ViewUnifiedChange): string {
// @TODO Highlighting
const lineRange: LineRange = {
from: change1 ? change1.getLineTo() : 1,
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) {
return '';
}
return this.motionRepo.extractMotionLineRange(this.motion.id, lineRange, true);
}
/**
* Returns true if this change is colliding with another change
* @param change
*/
public hasCollissions(change: ViewUnifiedChange): boolean {
// @TODO Implementation
return false;
}
/**
* Returns the diff string from the motion to the change
* @param {ViewUnifiedChange} change
*/
public getDiff(change: ViewUnifiedChange): SafeHtml {
const html = this.motionRepo.getChangeDiff(this.motion, change);
return this.sanitizer.bypassSecurityTrustHtml(html);
}
/**
* Returns the remainder text of the motion after the last change
*/
public getTextRemainderAfterLastChange(): string {
return this.motionRepo.getTextRemainderAfterLastChange(this.motion, this.changes);
}
/**
* Returns true if the change is a Change Recommendation
*
* @param {ViewUnifiedChange} change
*/
public isRecommendation(change: ViewUnifiedChange): boolean {
return change.getChangeType() === ViewUnifiedChangeType.TYPE_CHANGE_RECOMMENDATION;
}
/**
* Returns accepted, rejected or an empty string depending on the state of this change.
*
* @param change
*/
public getAcceptanceValue(change: ViewUnifiedChange): string {
if (change.isAccepted()) {
return 'accepted';
}
if (change.isRejected()) {
return 'rejected';
}
return '';
}
/**
* Returns true if the change is an Amendment
*
* @param {ViewUnifiedChange} change
*/
public isAmendment(change: ViewUnifiedChange): boolean {
return change.getChangeType() === ViewUnifiedChangeType.TYPE_AMENDMENT;
}
/**
* Returns true if the change is a Change Recommendation
*
* @param {ViewUnifiedChange} change
*/
public isChangeRecommendation(change: ViewUnifiedChange): boolean {
return change.getChangeType() === ViewUnifiedChangeType.TYPE_CHANGE_RECOMMENDATION;
}
/**
* Gets the name of the modification type
*
* @param change
*/
public getRecommendationTypeName(change: ViewChangeReco): string {
switch (change.type) {
case ModificationType.TYPE_REPLACEMENT:
return 'Replacement';
case ModificationType.TYPE_INSERTION:
return 'Insertion';
case ModificationType.TYPE_DELETION:
return 'Deletion';
default:
return '@UNKNOWN@';
}
}
/**
* Sets a change recommendation to accepted or rejected.
* The template has to make sure only to pass change recommendations to this method.
*
* @param {ViewChangeReco} change
* @param {string} value
*/
public setAcceptanceValue(change: ViewChangeReco, value: string): void {
if (value === 'accepted') {
this.recoRepo.setAccepted(change).subscribe(() => {}); // Subscribe to trigger HTTP request
}
if (value === 'rejected') {
this.recoRepo.setRejected(change).subscribe(() => {}); // Subscribe to trigger HTTP request
}
}
/**
* Deletes a change recommendation.
* The template has to make sure only to pass change recommendations to this method.
*
* @param {ViewChangeReco} reco
* @param {MouseEvent} $event
*/
public deleteChangeRecommendation(reco: ViewChangeReco, $event: MouseEvent): void {
this.recoRepo.delete(reco).subscribe(() => {}); // Subscribe to trigger HTTP request
$event.stopPropagation();
$event.preventDefault();
}
/**
* Edits a change recommendation.
* The template has to make sure only to pass change recommendations to this method.
*
* @param {ViewChangeReco} reco
* @param {MouseEvent} $event
*/
public editChangeRecommendation(reco: ViewChangeReco, $event: MouseEvent): void {
$event.stopPropagation();
$event.preventDefault();
const data: MotionChangeRecommendationComponentData = {
editChangeRecommendation: true,
newChangeRecommendation: false,
lineRange: {
from: reco.getLineFrom(),
to: reco.getLineTo()
},
changeRecommendation: reco
};
this.dialogService.open(MotionChangeRecommendationComponent, {
height: '400px',
width: '600px',
data: data
});
}
/**
* Scrolls to the native element specified by [scrollToChange]
*/
private scrollToChangeElement(change: ViewUnifiedChange): void {
const element = <HTMLElement>this.el.nativeElement;
const target = element.querySelector('[data-change-id="' + change.getChangeId() + '"]');
target.scrollIntoView({ behavior: 'smooth' });
}
public scrollToChangeClicked(change: ViewUnifiedChange, $event: MouseEvent): void {
$event.preventDefault();
$event.stopPropagation();
this.scrollToChangeElement(change);
}
/**
* Called from motion-detail-original-change-recommendations -> delegate to parent
*
* @param {LineRange} event
*/
public onCreateChangeRecommendation(event: LineRange): void {
this.createChangeRecommendation.emit(event);
}
public ngAfterViewInit(): void {
if (this.scrollToChange) {
window.setTimeout(() => {
this.scrollToChangeElement(this.scrollToChange);
}, 50);
}
}
}

View File

@ -0,0 +1,12 @@
<div class="text"></div>
<ul class="change-recommendation-list" *ngIf="showChangeRecommendations">
<li *ngFor="let reco of changeRecommendations"
[title]="reco.getTitle()"
[style.top]="calcRecoTop(reco)"
[style.height]="calcRecoHeight(reco)"
[class.delete]="recoIsDeletion(reco)"
[class.insert]="recoIsInsertion(reco)"
[class.replace]="recoIsReplacement(reco)"
(click)="gotoReco(reco)"
></li>
</ul>

View File

@ -0,0 +1,38 @@
.change-recommendation-list {
position: absolute;
top: 0;
left: -25px;
width: 4px;
list-style-type: none;
padding: 0;
margin: 0;
& > li {
position: absolute;
width: 4px;
left: 0;
cursor: pointer;
padding: 0;
margin: 0;
&.insert {
background-color: #00aa00;
}
&.delete {
background-color: #aa0000;
}
&.replace {
background-color: #0333ff;
}
&.other {
background-color: #777777;
}
}
.tooltip {
display: none;
}
}

View File

@ -0,0 +1,44 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { MotionDetailOriginalChangeRecommendationsComponent } from './motion-detail-original-change-recommendations.component';
import { E2EImportsModule } from '../../../../../e2e-imports.module';
import { Component } from '@angular/core';
@Component({
template: `
<os-motion-detail-original-change-recommendations
[html]="html"
[changeRecommendations]="changeRecommendations"
(createChangeRecommendation)="createChangeRecommendation($event)"
(gotoChangeRecommendation)="gotoChangeRecommendation($event)"
>
</os-motion-detail-original-change-recommendations>`
})
class TestHostComponent {
public html = '<p>Test123</p>';
public changeRecommendations = [];
public createChangeRecommendation($event: Event): void {}
public gotoChangeRecommendation($event: Event): void {}
}
describe('MotionDetailOriginalChangeRecommendationsComponent', () => {
let component: TestHostComponent;
let fixture: ComponentFixture<TestHostComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
declarations: [MotionDetailOriginalChangeRecommendationsComponent, TestHostComponent]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(TestHostComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,284 @@
import {
ElementRef,
Renderer2,
OnInit,
Output,
EventEmitter,
Input,
Component,
OnChanges,
SimpleChanges
} from '@angular/core';
import { LineRange, ModificationType } from '../../services/diff.service';
import { ViewChangeReco } from '../../models/view-change-reco';
import { OperatorService } from '../../../../core/services/operator.service';
/**
* This component displays the original motion text with annotated change commendations
* and a method to create new change recommendations from the line numbers to the left of the text.
* It's called from motion-details for displaying the whole motion text as well as from the diff view to show the
* unchanged parts of the motion.
*
* The line numbers are provided within the pre-rendered HTML, so we have to work with raw HTML and native HTML elements.
*
* It takes the styling from the parent component.
*
* ## Examples
*
* ```html
* <os-motion-detail-original-change-recommendations
* [html]="getFormattedText()"
* [changeRecommendations]="changeRecommendations"
* (createChangeRecommendation)="createChangeRecommendation($event)"
* (gotoChangeRecommendation)="gotoChangeRecommendation($event)"
* ></os-motion-detail-original-change-recommendations>
* ```
*/
@Component({
selector: 'os-motion-detail-original-change-recommendations',
templateUrl: './motion-detail-original-change-recommendations.component.html',
styleUrls: ['./motion-detail-original-change-recommendations.component.scss']
})
export class MotionDetailOriginalChangeRecommendationsComponent implements OnInit, OnChanges {
private element: Element;
private selectedFrom: number = null;
@Output()
public createChangeRecommendation: EventEmitter<LineRange> = new EventEmitter<LineRange>();
@Output()
public gotoChangeRecommendation: EventEmitter<ViewChangeReco> = new EventEmitter<ViewChangeReco>();
@Input()
public html: string;
@Input()
public changeRecommendations: ViewChangeReco[];
public showChangeRecommendations = false;
public can_manage = false;
/**
* @param {Renderer2} renderer
* @param {ElementRef} el
* @param {OperatorService} operator
*/
public constructor(private renderer: Renderer2, private el: ElementRef, private operator: OperatorService) {
this.operator.getObservable().subscribe(this.onPermissionsChanged.bind(this));
}
/**
* Re-creates
*/
private update(): void {
if (!this.element) {
// Not yet initialized
return;
}
this.element.innerHTML = this.html;
this.startCreating();
}
/**
* The permissions of the user have changed -> activate / deactivate editing functionality
*/
private onPermissionsChanged(): void {
if (this.operator.hasPerms('motions.can_manage')) {
this.can_manage = true;
if (this.selectedFrom === null) {
this.startCreating();
}
} else {
this.can_manage = false;
this.selectedFrom = null;
if (this.element) {
Array.from(this.element.querySelectorAll('.os-line-number')).forEach((lineNumber: Element) => {
lineNumber.classList.remove('selectable');
lineNumber.classList.remove('selected');
});
}
}
}
/**
* Returns an array with all line numbers that are currently affected by a change recommendation
* and therefor not subject to further changes
*/
private getAffectedLineNumbers(): number[] {
const affectedLines = [];
this.changeRecommendations.forEach((change: ViewChangeReco) => {
for (let j = change.line_from; j < change.line_to; j++) {
affectedLines.push(j);
}
});
return affectedLines;
}
/**
* Resetting the selection. All selected lines are unselected, and the selectable lines are marked as such
*/
private startCreating(): void {
if (!this.can_manage || !this.element) {
return;
}
const alreadyAffectedLines = this.getAffectedLineNumbers();
Array.from(this.element.querySelectorAll('.os-line-number')).forEach((lineNumber: Element) => {
lineNumber.classList.remove('selected');
if (alreadyAffectedLines.indexOf(parseInt(lineNumber.getAttribute('data-line-number'), 10)) === -1) {
lineNumber.classList.add('selectable');
} else {
lineNumber.classList.remove('selectable');
}
});
}
/**
* A line number has been clicked - either to start the selection or to finish it.
*
* @param lineNumber
*/
private clickedLineNumber(lineNumber: number): void {
if (this.selectedFrom === null) {
this.selectedFrom = lineNumber;
} else {
if (lineNumber > this.selectedFrom) {
this.createChangeRecommendation.emit({
from: this.selectedFrom,
to: lineNumber + 1
});
} else {
this.createChangeRecommendation.emit({
from: lineNumber,
to: this.selectedFrom + 1
});
}
this.selectedFrom = null;
this.startCreating();
}
}
/**
* A line number is hovered. If we are in the process of selecting a line range and the hovered line is selectable,
* the plus sign is shown for this line and all lines between the first selected line.
*
* @param lineNumberHovered
*/
private hoverLineNumber(lineNumberHovered: number): void {
if (this.selectedFrom === null) {
return;
}
Array.from(this.element.querySelectorAll('.os-line-number')).forEach((lineNumber: Element) => {
const line = parseInt(lineNumber.getAttribute('data-line-number'), 10);
if (
(line >= this.selectedFrom && line <= lineNumberHovered) ||
(line >= lineNumberHovered && line <= this.selectedFrom)
) {
lineNumber.classList.add('selected');
} else {
lineNumber.classList.remove('selected');
}
});
}
/**
* Style for the change recommendation list
* @param reco
*/
public calcRecoTop(reco: ViewChangeReco): string {
const from = <HTMLElement>(
this.element.querySelector('.os-line-number.line-number-' + reco.line_from.toString(10))
);
return from.offsetTop.toString() + 'px';
}
/**
* Style for the change recommendation list
* @param reco
*/
public calcRecoHeight(reco: ViewChangeReco): string {
const from = <HTMLElement>(
this.element.querySelector('.os-line-number.line-number-' + reco.line_from.toString(10))
);
const to = <HTMLElement>this.element.querySelector('.os-line-number.line-number-' + reco.line_to.toString(10));
if (to) {
return (to.offsetTop - from.offsetTop).toString() + 'px';
} else {
// Last line - lets assume a realistic value
return '20px';
}
}
/**
* CSS-Class for the change recommendation list
* @param reco
*/
public recoIsInsertion(reco: ViewChangeReco): boolean {
return reco.type === ModificationType.TYPE_INSERTION;
}
/**
* CSS-Class for the change recommendation list
* @param reco
*/
public recoIsDeletion(reco: ViewChangeReco): boolean {
return reco.type === ModificationType.TYPE_DELETION;
}
/**
* CSS-Class for the change recommendation list
* @param reco
*/
public recoIsReplacement(reco: ViewChangeReco): boolean {
return reco.type === ModificationType.TYPE_REPLACEMENT;
}
/**
* Trigger the `gotoChangeRecommendation`-event
* @param reco
*/
public gotoReco(reco: ViewChangeReco): void {
this.gotoChangeRecommendation.emit(reco);
}
/**
* Adding the event listeners: clicking on plus signs next to line numbers
* and the hover-event next to the line numbers
*/
public ngOnInit(): void {
const nativeElement = <Element>this.el.nativeElement;
this.element = <Element>nativeElement.querySelector('.text');
this.renderer.listen(this.el.nativeElement, 'click', (ev: MouseEvent) => {
const element = <Element>ev.target;
if (element.classList.contains('os-line-number') && element.classList.contains('selectable')) {
this.clickedLineNumber(parseInt(element.getAttribute('data-line-number'), 10));
}
});
this.renderer.listen(this.el.nativeElement, 'mouseover', (ev: MouseEvent) => {
const element = <Element>ev.target;
if (element.classList.contains('os-line-number') && element.classList.contains('selectable')) {
this.hoverLineNumber(parseInt(element.getAttribute('data-line-number'), 10));
}
});
this.update();
// The positioning of the change recommendations depends on the rendered HTML
// If we show it right away, there will be nasty Angular warnings about changed values, as the position
// is changing while the DOM updates
window.setTimeout(() => {
this.showChangeRecommendations = true;
}, 1);
}
/**
* @param changes
*/
public ngOnChanges(changes: SimpleChanges): void {
this.update();
}
}

View File

@ -50,7 +50,7 @@
<mat-accordion multi='true' class='on-transition-fade'>
<!-- MetaInfo Panel-->
<mat-expansion-panel #metaInfoPanel [expanded]='this.editMotion && this.newMotion' class='meta-info-block meta-info-panel'>
<mat-expansion-panel #metaInfoPanel [expanded]="this.editReco && this.newReco" class='meta-info-block meta-info-panel'>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>info</mat-icon>
@ -275,12 +275,27 @@
<!-- Text -->
<!-- TODO: this is a config variable. Read it out -->
<h3 translate>The assembly may decide:</h3>
<div *ngIf='motion && !editMotion' class="motion-text"
<ng-container *ngIf='motion && !editMotion'>
<div *ngIf="!isRecoModeDiff()" class="motion-text"
[class.line-numbers-none]="isLineNumberingNone()"
[class.line-numbers-inline]="isLineNumberingInline()"
[class.line-numbers-outside]="isLineNumberingOutside()">
<div [innerHtml]="getFormattedText()"></div>
<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>
</div>
<os-motion-detail-diff *ngIf="isRecoModeDiff()"
[motion]="motion"
[changes]="allChangingObjects"
[scrollToChange]="scrollToChange"
(createChangeRecommendation)="createChangeRecommendation($event)"
></os-motion-detail-diff>
</ng-container>
<mat-form-field *ngIf="motion && editMotion" class="wide-form">
<textarea matInput placeholder='Motion Text' formControlName='text' [value]='motionCopy.text'></textarea>
</mat-form-field>

View File

@ -166,12 +166,14 @@ mat-expansion-panel {
// which doesn't have the [ngcontent]-attributes necessary for regular styles.
// An alternative approach (in case ::ng-deep gets removed) might be to change the view encapsulation.
:host ::ng-deep .motion-text {
ins {
ins,
.insert {
color: green;
text-decoration: underline;
}
del {
del,
.delete {
color: red;
text-decoration: line-through;
}
@ -212,6 +214,20 @@ mat-expansion-panel {
font-size: 12px;
font-weight: normal;
}
&.selectable:hover:before,
&.selected:before {
position: absolute;
top: 4px;
left: 20px;
display: inline-block;
cursor: pointer;
content: '';
width: 16px;
height: 16px;
background: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M19 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-2 10h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" fill="%23337ab7"/><path d="M0 0h24v24H0z" fill="none"/></svg>');
background-size: 16px 16px;
}
}
}

View File

@ -2,6 +2,7 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { MotionDetailComponent } from './motion-detail.component';
import { E2EImportsModule } from '../../../../../e2e-imports.module';
import { MotionsModule } from '../../motions.module';
describe('MotionDetailComponent', () => {
let component: MotionDetailComponent;
@ -9,8 +10,8 @@ describe('MotionDetailComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
declarations: [MotionDetailComponent]
imports: [E2EImportsModule, MotionsModule],
declarations: []
}).compileComponents();
}));

View File

@ -1,19 +1,27 @@
import { Component, OnInit, ViewChild } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { MatExpansionPanel } from '@angular/material';
import { MatDialog, MatExpansionPanel } from '@angular/material';
import { BaseComponent } from '../../../../base.component';
import { Category } from '../../../../shared/models/motions/category';
import { ViewportService } from '../../../../core/services/viewport.service';
import { MotionRepositoryService } from '../../services/motion-repository.service';
import { LineNumbering, ViewMotion } from '../../models/view-motion';
import { ChangeRecoMode, LineNumberingMode, ViewMotion } from '../../models/view-motion';
import { User } from '../../../../shared/models/users/user';
import { DataStoreService } from '../../../../core/services/data-store.service';
import { TranslateService } from '@ngx-translate/core';
import { Motion } from '../../../../shared/models/motions/motion';
import { BehaviorSubject } from 'rxjs';
import { SafeHtml } from '@angular/platform-browser';
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';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { ViewUnifiedChange } from '../../models/view-unified-change';
/**
* Component for the motion detail view
@ -68,6 +76,16 @@ export class MotionDetailComponent extends BaseComponent implements OnInit {
*/
public motionCopy: ViewMotion;
/**
* All change recommendations to this motion
*/
public changeRecommendations: ViewChangeReco[];
/**
* All change recommendations AND amendments, sorted by line number.
*/
public allChangingObjects: ViewUnifiedChange[];
/**
* Subject for the Categories
*/
@ -83,6 +101,11 @@ export class MotionDetailComponent extends BaseComponent implements OnInit {
*/
public supporterObserver: BehaviorSubject<Array<User>>;
/**
* Value for os-motion-detail-diff: when this is set, that component scrolls to the given change
*/
public scrollToChange: ViewUnifiedChange = null;
/**
* Constuct the detail view.
*
@ -90,7 +113,11 @@ export class MotionDetailComponent extends BaseComponent implements OnInit {
* @param router to navigate back to the motion list and to an existing motion
* @param route determine if this is a new or an existing motion
* @param formBuilder For reactive forms. Form Group and Form Control
* @param dialogService For opening dialogs
* @param repo: Motion Repository
* @param changeRecoRepo: Change Recommendation Repository
* @param DS: The DataStoreService
* @param sanitizer: For making HTML SafeHTML
* @param translate: Translation Service
*/
public constructor(
@ -98,8 +125,11 @@ export class MotionDetailComponent extends BaseComponent implements OnInit {
private router: Router,
private route: ActivatedRoute,
private formBuilder: FormBuilder,
private dialogService: MatDialog,
private repo: MotionRepositoryService,
private changeRecoRepo: ChangeRecommendationRepositoryService,
private DS: DataStoreService,
private sanitizer: DomSanitizer,
protected translate: TranslateService
) {
super();
@ -119,6 +149,12 @@ export class MotionDetailComponent extends BaseComponent implements OnInit {
this.repo.getViewModelObservable(params.id).subscribe(newViewMotion => {
this.motion = newViewMotion;
});
this.changeRecoRepo
.getChangeRecosOfMotionObservable(parseInt(params.id, 10))
.subscribe((recos: ViewChangeReco[]) => {
this.changeRecommendations = recos;
this.recalcUnifiedChanges();
});
});
}
// Initial Filling of the Subjects
@ -138,6 +174,24 @@ export class MotionDetailComponent extends BaseComponent implements OnInit {
});
}
/**
* 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;
}
});
}
/**
* Async load the values of the motion in the Form.
*/
@ -216,15 +270,25 @@ export class MotionDetailComponent extends BaseComponent implements OnInit {
/**
* get the formated motion text from the repository.
*/
public getFormattedText(): SafeHtml {
public getFormattedTextPlain(): string {
// Prevent this.allChangingObjects to be reordered from within formatMotion
const changes: ViewUnifiedChange[] = Object.assign([], this.allChangingObjects);
return this.repo.formatMotion(
this.motion.id,
this.motion.crMode,
changes,
this.motion.lineLength,
this.motion.highlightedLine
);
}
/**
* get the formated motion text from the repository, as SafeHTML for [innerHTML]
*/
public getFormattedText(): SafeHtml {
return this.sanitizer.bypassSecurityTrustHtml(this.getFormattedTextPlain());
}
/**
* Click on the edit button (pen-symbol)
*/
@ -270,7 +334,7 @@ export class MotionDetailComponent extends BaseComponent implements OnInit {
* Sets the motions line numbering mode
* @param mode Needs to fot to the enum defined in ViewMotion
*/
public setLineNumberingMode(mode: LineNumbering): void {
public setLineNumberingMode(mode: LineNumberingMode): void {
this.motion.lnMode = mode;
}
@ -278,21 +342,21 @@ export class MotionDetailComponent extends BaseComponent implements OnInit {
* Returns true if no line numbers are to be shown.
*/
public isLineNumberingNone(): boolean {
return this.motion.lnMode === LineNumbering.None;
return this.motion.lnMode === LineNumberingMode.None;
}
/**
* Returns true if the line numbers are to be shown within the text with no line breaks.
*/
public isLineNumberingInline(): boolean {
return this.motion.lnMode === LineNumbering.Inside;
return this.motion.lnMode === LineNumberingMode.Inside;
}
/**
* Returns true if the line numbers are to be shown to the left of the text.
*/
public isLineNumberingOutside(): boolean {
return this.motion.lnMode === LineNumbering.Outside;
return this.motion.lnMode === LineNumberingMode.Outside;
}
/**
@ -303,6 +367,48 @@ export class MotionDetailComponent extends BaseComponent implements OnInit {
this.motion.crMode = mode;
}
/**
* 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);
}
/**
* Init. Does nothing here.
*/

View File

@ -0,0 +1,100 @@
import { BaseViewModel } from '../../base/base-view-model';
import { MotionChangeReco } from '../../../shared/models/motions/motion-change-reco';
import { BaseModel } from '../../../shared/models/base/base-model';
import { ModificationType } from '../services/diff.service';
import { ViewUnifiedChange, ViewUnifiedChangeType } from './view-unified-change';
/**
* Change recommendation class for the View
*
* Stores a motion including all (implicit) references
* Provides "safe" access to variables and functions in {@link MotionChangeReco}
* @ignore
*/
export class ViewChangeReco extends BaseViewModel implements ViewUnifiedChange {
private _changeReco: MotionChangeReco;
public get id(): number {
return this._changeReco ? this._changeReco.id : null;
}
public get changeRecommendation(): MotionChangeReco {
return this._changeReco;
}
public constructor(changeReco?: MotionChangeReco) {
super();
this._changeReco = changeReco;
}
public getTitle(): string {
return this._changeReco.getTitle();
}
public updateValues(update: BaseModel): void {
// @TODO Is there any need for this function?
}
public updateChangeReco(type: number, text: string): void {
// @TODO HTML sanitazion
this._changeReco.type = type;
this._changeReco.text = text;
}
public get rejected(): boolean {
return this._changeReco ? this._changeReco.rejected : null;
}
public get type(): number {
return this._changeReco ? this._changeReco.type : ModificationType.TYPE_REPLACEMENT;
}
public get other_description(): string {
return this._changeReco ? this._changeReco.other_description : null;
}
public get line_from(): number {
return this._changeReco ? this._changeReco.line_from : null;
}
public get line_to(): number {
return this._changeReco ? this._changeReco.line_to : null;
}
public get text(): string {
return this._changeReco ? this._changeReco.text : null;
}
public get motion_id(): number {
return this._changeReco ? this._changeReco.motion_id : null;
}
public getChangeId(): string {
return 'recommendation-' + this.id.toString(10);
}
public getChangeType(): ViewUnifiedChangeType {
return ViewUnifiedChangeType.TYPE_CHANGE_RECOMMENDATION;
}
public getLineFrom(): number {
return this.line_from;
}
public getLineTo(): number {
return this.line_to;
}
public getChangeNewText(): string {
return this.text;
}
public isAccepted(): boolean {
return !this.rejected;
}
public isRejected(): boolean {
return this.rejected;
}
}

View File

@ -6,15 +6,15 @@ import { WorkflowState } from '../../../shared/models/motions/workflow-state';
import { BaseModel } from '../../../shared/models/base/base-model';
import { BaseViewModel } from '../../base/base-view-model';
export enum LineNumbering {
export enum LineNumberingMode {
None,
Inside,
Outside
}
enum ChangeReco {
export enum ChangeRecoMode {
Original,
Change,
Changed,
Diff,
Final
}
@ -35,16 +35,16 @@ export class ViewMotion extends BaseViewModel {
private _state: WorkflowState;
/**
* Indicates the LineNumbering Mode.
* Indicates the LineNumberingMode Mode.
* Needs to be accessed from outside
*/
public lnMode: LineNumbering;
public lnMode: LineNumberingMode;
/**
* Indicates the Change reco Mode.
* Needs to be accessed from outside
*/
public crMode: ChangeReco;
public crMode: ChangeRecoMode;
/**
* Indicates the maximum line length as defined in the configuration.
@ -189,8 +189,8 @@ export class ViewMotion extends BaseViewModel {
this._state = state;
// TODO: Should be set using a a config variable
this.lnMode = LineNumbering.None;
this.crMode = ChangeReco.Original;
this.lnMode = LineNumberingMode.Outside;
this.crMode = ChangeRecoMode.Original;
this.lineLength = 80;
this.highlightedLine = null;

View File

@ -0,0 +1,46 @@
export enum ViewUnifiedChangeType {
TYPE_CHANGE_RECOMMENDATION,
TYPE_AMENDMENT
}
/**
* A common interface for (paragraph-based) amendments and change recommendations.
* Needed to merge both types of change objects in the motion content at the same time
*/
export interface ViewUnifiedChange {
/**
* Returns the type of change
*/
getChangeType(): ViewUnifiedChangeType;
/**
* An id that is unique considering both change recommendations and amendments, therefore needs to be
* "namespaced" (e.g. "amendment.23" or "recommendation.42")
*/
getChangeId(): string;
/**
* First line number of the change
*/
getLineFrom(): number;
/**
* Last line number of the change (the line number marking the end of the change - not the number of the last line)
*/
getLineTo(): number;
/**
* Returns the new version of the text, as it would be if this change was to be adopted.
*/
getChangeNewText(): string;
/**
* True, if accepted. False, if rejected or undecided.
*/
isAccepted(): boolean;
/**
* True, if rejected. False, if accepted or undecided.
*/
isRejected(): boolean;
}

View File

@ -8,6 +8,9 @@ import { MotionDetailComponent } from './components/motion-detail/motion-detail.
import { CategoryListComponent } from './components/category-list/category-list.component';
import { MotionCommentSectionListComponent } from './components/motion-comment-section-list/motion-comment-section-list.component';
import { StatuteParagraphListComponent } from './components/statute-paragraph-list/statute-paragraph-list.component';
import { MotionChangeRecommendationComponent } from './components/motion-change-recommendation/motion-change-recommendation.component';
import { MotionDetailOriginalChangeRecommendationsComponent } from './components/motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component';
import { MotionDetailDiffComponent } from './components/motion-detail-diff/motion-detail-diff.component';
@NgModule({
imports: [CommonModule, MotionsRoutingModule, SharedModule],
@ -16,7 +19,12 @@ import { StatuteParagraphListComponent } from './components/statute-paragraph-li
MotionDetailComponent,
CategoryListComponent,
MotionCommentSectionListComponent,
StatuteParagraphListComponent
]
StatuteParagraphListComponent,
MotionChangeRecommendationComponent,
MotionCommentSectionListComponent,
MotionDetailOriginalChangeRecommendationsComponent,
MotionDetailDiffComponent
],
entryComponents: [MotionChangeRecommendationComponent]
})
export class MotionsModule {}

View File

@ -0,0 +1,20 @@
import { TestBed, inject } from '@angular/core/testing';
import { ChangeRecommendationRepositoryService } from './change-recommendation-repository.service';
import { E2EImportsModule } from '../../../../e2e-imports.module';
describe('ChangeRecommendationRepositoryService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
providers: [ChangeRecommendationRepositoryService]
});
});
it('should be created', inject(
[ChangeRecommendationRepositoryService],
(service: ChangeRecommendationRepositoryService) => {
expect(service).toBeTruthy();
}
));
});

View File

@ -0,0 +1,135 @@
import { Injectable } from '@angular/core';
import { DataSendService } from '../../../core/services/data-send.service';
import { User } from '../../../shared/models/users/user';
import { Category } from '../../../shared/models/motions/category';
import { Workflow } from '../../../shared/models/motions/workflow';
import { Observable } from 'rxjs';
import { BaseRepository } from '../../base/base-repository';
import { DataStoreService } from '../../../core/services/data-store.service';
import { MotionChangeReco } from '../../../shared/models/motions/motion-change-reco';
import { ViewChangeReco } from '../models/view-change-reco';
import { map } from 'rxjs/operators';
/**
* Repository Services for change recommendations
*
* The repository is meant to process domain objects (those found under
* shared/models), so components can display them and interact with them.
*
* Rather than manipulating models directly, the repository is meant to
* inform the {@link DataSendService} about changes which will send
* them to the Server.
*/
@Injectable({
providedIn: 'root'
})
export class ChangeRecommendationRepositoryService extends BaseRepository<ViewChangeReco, MotionChangeReco> {
/**
* Creates a MotionRepository
*
* Converts existing and incoming motions to ViewMotions
* Handles CRUD using an observer to the DataStore
* @param DS
* @param dataSend
*/
public constructor(DS: DataStoreService, private dataSend: DataSendService) {
super(DS, MotionChangeReco, [Category, User, Workflow]);
}
/**
* Creates a change recommendation
* Creates a (real) change recommendation and delegates it to the {@link DataSendService}
*
* @param {MotionChangeReco} changeReco
*/
public create(changeReco: MotionChangeReco): Observable<MotionChangeReco> {
return this.dataSend.createModel(changeReco) as Observable<MotionChangeReco>;
}
/**
* Given a change recommendation view object, a entry in the backend is created and the new
* change recommendation view object is returned (as an observable).
*
* @param {ViewChangeReco} view
*/
public createByViewModel(view: ViewChangeReco): Observable<ViewChangeReco> {
return this.create(view.changeRecommendation).pipe(
map((changeReco: MotionChangeReco) => {
return new ViewChangeReco(changeReco);
})
);
}
/**
* Creates this view wrapper based on an actual Change Recommendation model
*
* @param {MotionChangeReco} model
*/
protected createViewModel(model: MotionChangeReco): ViewChangeReco {
return new ViewChangeReco(model);
}
/**
* Deleting a change recommendation.
*
* Extract the change recommendation out of the viewModel and delegate
* to {@link DataSendService}
* @param {ViewChangeReco} viewModel
*/
public delete(viewModel: ViewChangeReco): Observable<MotionChangeReco> {
return this.dataSend.delete(viewModel.changeRecommendation) as Observable<MotionChangeReco>;
}
/**
* updates a change recommendation
*
* Updates a (real) change recommendation with patched data and delegate it
* to the {@link DataSendService}
*
* @param {Partial<MotionChangeReco>} update the form data containing the update values
* @param {ViewChangeReco} viewModel The View Change Recommendation. If not present, a new motion will be created
*/
public update(update: Partial<MotionChangeReco>, viewModel: ViewChangeReco): Observable<MotionChangeReco> {
const changeReco = viewModel.changeRecommendation;
changeReco.patchValues(update);
return this.dataSend.updateModel(changeReco, 'patch') as Observable<MotionChangeReco>;
}
/**
* return the Observable of all change recommendations belonging to the given motion
*/
public getChangeRecosOfMotionObservable(motion_id: number): Observable<ViewChangeReco[]> {
return this.viewModelListSubject.asObservable().pipe(
map((recos: ViewChangeReco[]) => {
return recos.filter(reco => reco.motion_id === motion_id);
})
);
}
/**
* Sets a change recommendation to accepted.
*
* @param {ViewChangeReco} change
*/
public setAccepted(change: ViewChangeReco): Observable<MotionChangeReco> {
const changeReco = change.changeRecommendation;
changeReco.patchValues({
rejected: false
});
return this.dataSend.updateModel(changeReco, 'patch') as Observable<MotionChangeReco>;
}
/**
* Sets a change recommendation to rejected.
*
* @param {ViewChangeReco} change
*/
public setRejected(change: ViewChangeReco): Observable<MotionChangeReco> {
const changeReco = change.changeRecommendation;
changeReco.patchValues({
rejected: true
});
return this.dataSend.updateModel(changeReco, 'patch') as Observable<MotionChangeReco>;
}
}

View File

@ -1,5 +1,7 @@
import { Injectable } from '@angular/core';
import { LinenumberingService } from './linenumbering.service';
import { ViewMotion } from '../models/view-motion';
import { ViewUnifiedChange } from '../models/view-unified-change';
const ELEMENT_NODE = 1;
const TEXT_NODE = 3;
@ -99,7 +101,7 @@ interface ExtractedContent {
/**
* An object specifying a range of line numbers.
*/
interface LineRange {
export interface LineRange {
/**
* The first line number to be included.
*/
@ -173,10 +175,16 @@ interface LineRange {
providedIn: 'root'
})
export class DiffService {
// @TODO Decide on a more sophisticated implementation
private diffCache = {
get: (key: string) => undefined,
put: (key: string, val: any) => undefined
}; // @TODO
_cache: {},
get: (key: string): any => {
return this.diffCache._cache[key] === undefined ? null : this.diffCache._cache[key];
},
put: (key: string, val: any): void => {
this.diffCache._cache[key] = val;
}
};
/**
* Creates the DiffService.
@ -1983,4 +1991,41 @@ export class DiffService {
this.diffCache.put(cacheKey, diff);
return diff;
}
/**
* Applies all given changes to the motion and returns the (line-numbered) text
*
* @param {ViewMotion} motion
* @param {ViewUnifiedChange[]} changes
* @param {number} lineLength
* @param {number} highlightLine
*/
public getTextWithChanges(
motion: ViewMotion,
changes: ViewUnifiedChange[],
lineLength: number,
highlightLine: number
): string {
let html = motion.text;
// Changes need to be applied from the bottom up, to prevent conflicts with changing line numbers.
changes.sort((change1: ViewUnifiedChange, change2: ViewUnifiedChange) => {
if (change1.getLineFrom() < change2.getLineFrom()) {
return 1;
} else if (change1.getLineFrom() > change2.getLineFrom()) {
return -1;
} else {
return 0;
}
});
changes.forEach((change: ViewUnifiedChange) => {
html = this.lineNumberingService.insertLineNumbers(html, lineLength, null, null, 1);
html = this.replaceLines(html, change.getChangeNewText(), change.getLineFrom(), change.getLineTo());
});
html = this.lineNumberingService.insertLineNumbers(html, lineLength, highlightLine, null, 1);
return html;
}
}

View File

@ -90,14 +90,19 @@ interface SectionHeading {
})
export class LinenumberingService {
/**
* @TODO
* @TODO Decide on a more sophisticated implementation
* This is just a stub for a caching system. The original code from Angular1 was:
* var lineNumberCache = $cacheFactory('linenumbering.service');
* This should be replaced by a real cache once we have decided on a caching service for OpenSlides 3
*/
private lineNumberCache = {
get: (key: string) => undefined,
put: (key: string, val: any) => undefined
_cache: {},
get: (key: string): any => {
return this.lineNumberCache._cache[key] === undefined ? null : this.lineNumberCache._cache[key];
},
put: (key: string, val: any): void => {
this.lineNumberCache._cache[key] = val;
}
};
// Counts the number of characters in the current line, beyond singe nodes.

View File

@ -6,12 +6,15 @@ import { User } from '../../../shared/models/users/user';
import { Category } from '../../../shared/models/motions/category';
import { Workflow } from '../../../shared/models/motions/workflow';
import { WorkflowState } from '../../../shared/models/motions/workflow-state';
import { ViewMotion } from '../models/view-motion';
import { ChangeRecoMode, ViewMotion } from '../models/view-motion';
import { Observable } from 'rxjs';
import { BaseRepository } from '../../base/base-repository';
import { DataStoreService } from '../../../core/services/data-store.service';
import { LinenumberingService } from './linenumbering.service';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { 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';
/**
* Repository Services for motions (and potentially categories)
@ -32,15 +35,16 @@ export class MotionRepositoryService extends BaseRepository<ViewMotion, Motion>
*
* Converts existing and incoming motions to ViewMotions
* Handles CRUD using an observer to the DataStore
* @param DS
* @param dataSend
* @param lineNumbering
* @param {DataStoreService} DS
* @param {DataSendService} dataSend
* @param {LinenumberingService} lineNumbering
* @param {DiffService} diff
*/
public constructor(
DS: DataStoreService,
private dataSend: DataSendService,
private readonly lineNumbering: LinenumberingService,
private readonly sanitizer: DomSanitizer
private readonly diff: DiffService
) {
super(DS, Motion, [Category, User, Workflow]);
}
@ -111,41 +115,209 @@ export class MotionRepositoryService extends BaseRepository<ViewMotion, Motion>
* Format the motion text using the line numbering and change
* reco algorithm.
*
* TODO: Call DiffView Service here.
*
* Can be called from detail view and exporter
* @param id Motion ID - will be pulled from the repository
* @param crMode indicator for the change reco mode
* @param changes all change recommendations and amendments, sorted by line number
* @param lineLength the current line
* @param highlightLine the currently highlighted line (default: none)
*/
public formatMotion(id: number, crMode: number, lineLength: number, highlightLine?: number): SafeHtml {
public formatMotion(
id: number,
crMode: ChangeRecoMode,
changes: ViewUnifiedChange[],
lineLength: number,
highlightLine?: number
): string {
const targetMotion = this.getViewModel(id);
if (targetMotion && targetMotion.text) {
let motionText = targetMotion.text;
motionText = this.lineNumbering.insertLineNumbers(motionText, lineLength, highlightLine);
// TODO : Use Diff Service here.
// this will(currently) append the previous changes.
// update
switch (crMode) {
case 0: // Original
break;
case 1: // Changed Version
motionText += ' and get changed version';
break;
case 2: // Diff Version
motionText += ' and get diff version';
break;
case 3: // Final Version
motionText += ' and final version';
break;
case ChangeRecoMode.Original:
return this.lineNumbering.insertLineNumbers(targetMotion.text, lineLength, highlightLine);
case ChangeRecoMode.Changed:
return this.diff.getTextWithChanges(targetMotion, changes, lineLength, highlightLine);
case ChangeRecoMode.Diff:
let text = '';
changes.forEach((change: ViewUnifiedChange, idx: number) => {
if (idx === 0) {
text += this.extractMotionLineRange(
id,
{
from: 1,
to: change.getLineFrom()
},
true
);
} else if (changes[idx - 1].getLineTo() < change.getLineFrom()) {
text += this.extractMotionLineRange(
id,
{
from: changes[idx - 1].getLineTo(),
to: change.getLineFrom()
},
true
);
}
text += this.getChangeDiff(targetMotion, change, highlightLine);
});
text += this.getTextRemainderAfterLastChange(targetMotion, changes, highlightLine);
return text;
case ChangeRecoMode.Final:
const appliedChanges: ViewUnifiedChange[] = changes.filter(change => change.isAccepted());
return this.diff.getTextWithChanges(targetMotion, appliedChanges, lineLength, highlightLine);
default:
console.error('unrecognized ChangeRecoMode option');
return null;
}
return this.sanitizer.bypassSecurityTrustHtml(motionText);
} else {
return null;
}
}
/**
* Extracts a renderable HTML string representing the given line number range of this motion
*
* @param {number} id
* @param {LineRange} lineRange
* @param {boolean} lineNumbers - weather to add line numbers to the returned HTML string
*/
public extractMotionLineRange(id: number, lineRange: LineRange, lineNumbers: boolean): string {
// @TODO flexible line numbers
const origHtml = this.formatMotion(id, ChangeRecoMode.Original, [], 80);
const extracted = this.diff.extractRangeByLineNumbers(origHtml, lineRange.from, lineRange.to);
let html =
extracted.outerContextStart +
extracted.innerContextStart +
extracted.html +
extracted.innerContextEnd +
extracted.outerContextEnd;
if (lineNumbers) {
html = this.lineNumbering.insertLineNumbers(html, 80, null, null, lineRange.from);
}
return html;
}
/**
* Returns the remainder text of the motion after the last change
*
* @param {ViewMotion} motion
* @param {ViewUnifiedChange[]} changes
* @param {number} highlight
*/
public getTextRemainderAfterLastChange(
motion: ViewMotion,
changes: ViewUnifiedChange[],
highlight?: number
): string {
let maxLine = 0;
changes.forEach((change: ViewUnifiedChange) => {
if (change.getLineTo() > maxLine) {
maxLine = change.getLineTo();
}
}, 0);
const numberedHtml = this.lineNumbering.insertLineNumbers(motion.text, motion.lineLength);
let data;
try {
data = this.diff.extractRangeByLineNumbers(numberedHtml, maxLine, null);
} catch (e) {
// This only happens (as far as we know) when the motion text has been altered (shortened)
// without modifying the change recommendations accordingly.
// That's a pretty serious inconsistency that should not happen at all,
// we're just doing some basic damage control here.
const msg =
'Inconsistent data. A change recommendation is probably referring to a non-existant line number.';
return '<em style="color: red; font-weight: bold;">' + msg + '</em>';
}
let html;
if (data.html !== '') {
// Add "merge-before"-css-class if the first line begins in the middle of a paragraph. Used for PDF.
html =
this.diff.addCSSClassToFirstTag(data.outerContextStart + data.innerContextStart, 'merge-before') +
data.html +
data.innerContextEnd +
data.outerContextEnd;
html = this.lineNumbering.insertLineNumbers(html, motion.lineLength, highlight, null, maxLine);
} else {
// Prevents empty lines at the end of the motion
html = '';
}
return html;
}
/**
* Creates a {@link ViewChangeReco} object based on the motion ID and the given lange range.
* This object is not saved yet and does not yet have any changed HTML. It's meant to populate the UI form.
*
* @param {number} motionId
* @param {LineRange} lineRange
*/
public createChangeRecommendationTemplate(motionId: number, lineRange: LineRange): ViewChangeReco {
const changeReco = new MotionChangeReco();
changeReco.line_from = lineRange.from;
changeReco.line_to = lineRange.to;
changeReco.type = ModificationType.TYPE_REPLACEMENT;
changeReco.text = this.extractMotionLineRange(motionId, lineRange, false);
changeReco.rejected = false;
changeReco.motion_id = motionId;
return new ViewChangeReco(changeReco);
}
/**
* Returns the HTML with the changes, optionally with a highlighted line.
* The original motion needs to be provided.
*
* @param {ViewMotion} motion
* @param {ViewUnifiedChange} change
* @param {number} highlight
*/
public getChangeDiff(motion: ViewMotion, change: ViewUnifiedChange, highlight?: number): string {
const lineLength = motion.lineLength,
html = this.lineNumbering.insertLineNumbers(motion.text, lineLength);
let data, oldText;
try {
data = this.diff.extractRangeByLineNumbers(html, change.getLineFrom(), change.getLineTo());
oldText =
data.outerContextStart +
data.innerContextStart +
data.html +
data.innerContextEnd +
data.outerContextEnd;
} catch (e) {
// This only happens (as far as we know) when the motion text has been altered (shortened)
// without modifying the change recommendations accordingly.
// That's a pretty serious inconsistency that should not happen at all,
// we're just doing some basic damage control here.
const msg =
'Inconsistent data. A change recommendation is probably referring to a non-existant line number.';
return '<em style="color: red; font-weight: bold;">' + msg + '</em>';
}
oldText = this.lineNumbering.insertLineNumbers(oldText, lineLength, null, null, change.getLineFrom());
let diff = this.diff.diff(oldText, change.getChangeNewText());
// If an insertion makes the line longer than the line length limit, we need two line breaking runs:
// - First, for the official line numbers, ignoring insertions (that's been done some lines before)
// - Second, another one to prevent the displayed including insertions to exceed the page width
diff = this.lineNumbering.insertLineBreaksWithoutNumbers(diff, lineLength, true);
if (highlight > 0) {
diff = this.lineNumbering.highlightLine(diff, highlight);
}
const origBeginning = data.outerContextStart + data.innerContextStart;
if (diff.toLowerCase().indexOf(origBeginning.toLowerCase()) === 0) {
// Add "merge-before"-css-class if the first line begins in the middle of a paragraph. Used for PDF.
diff =
this.diff.addCSSClassToFirstTag(origBeginning, 'merge-before') + diff.substring(origBeginning.length);
}
return diff;
}
}