Merge pull request #3907 from CatoTH/OpenSlides-3-ChangeRecommendations
Line Numbering / Change Recommendations
This commit is contained in:
commit
794b978627
@ -6,7 +6,7 @@ import { BaseModel } from '../base/base-model';
|
|||||||
*/
|
*/
|
||||||
export class MotionChangeReco extends BaseModel<MotionChangeReco> {
|
export class MotionChangeReco extends BaseModel<MotionChangeReco> {
|
||||||
public id: number;
|
public id: number;
|
||||||
public motion_version_id: number;
|
public motion_id: number;
|
||||||
public rejected: boolean;
|
public rejected: boolean;
|
||||||
public type: number;
|
public type: number;
|
||||||
public other_description: string;
|
public other_description: string;
|
||||||
|
@ -20,10 +20,12 @@ import {
|
|||||||
MatDatepickerModule,
|
MatDatepickerModule,
|
||||||
MatNativeDateModule,
|
MatNativeDateModule,
|
||||||
DateAdapter,
|
DateAdapter,
|
||||||
MatIconModule
|
MatIconModule,
|
||||||
|
MatButtonToggleModule
|
||||||
} from '@angular/material';
|
} from '@angular/material';
|
||||||
import { MatAutocompleteModule } from '@angular/material/autocomplete';
|
import { MatAutocompleteModule } from '@angular/material/autocomplete';
|
||||||
import { MatChipsModule } from '@angular/material';
|
import { MatChipsModule } from '@angular/material';
|
||||||
|
import { MatRadioModule } from '@angular/material';
|
||||||
import { NgxMatSelectSearchModule } from 'ngx-mat-select-search';
|
import { NgxMatSelectSearchModule } from 'ngx-mat-select-search';
|
||||||
import { MatDialogModule } from '@angular/material/dialog';
|
import { MatDialogModule } from '@angular/material/dialog';
|
||||||
import { MatListModule } from '@angular/material/list';
|
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)
|
// we either wait or include a fixed version manually (dirty)
|
||||||
// https://github.com/google/material-design-icons/issues/786
|
// https://github.com/google/material-design-icons/issues/786
|
||||||
MatIconModule,
|
MatIconModule,
|
||||||
|
MatRadioModule,
|
||||||
|
MatButtonToggleModule,
|
||||||
TranslateModule.forChild(),
|
TranslateModule.forChild(),
|
||||||
RouterModule,
|
RouterModule,
|
||||||
NgxMatSelectSearchModule
|
NgxMatSelectSearchModule
|
||||||
@ -117,6 +121,8 @@ import { PromptDialogComponent } from './components/prompt-dialog/prompt-dialog.
|
|||||||
MatChipsModule,
|
MatChipsModule,
|
||||||
MatTooltipModule,
|
MatTooltipModule,
|
||||||
MatIconModule,
|
MatIconModule,
|
||||||
|
MatRadioModule,
|
||||||
|
MatButtonToggleModule,
|
||||||
NgxMatSelectSearchModule,
|
NgxMatSelectSearchModule,
|
||||||
TranslateModule,
|
TranslateModule,
|
||||||
PermsDirective,
|
PermsDirective,
|
||||||
|
@ -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>
|
@ -0,0 +1,9 @@
|
|||||||
|
.wide-form {
|
||||||
|
textarea {
|
||||||
|
height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::ng-deep {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
@ -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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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>
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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>
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
@ -50,7 +50,7 @@
|
|||||||
<mat-accordion multi='true' class='on-transition-fade'>
|
<mat-accordion multi='true' class='on-transition-fade'>
|
||||||
|
|
||||||
<!-- MetaInfo Panel-->
|
<!-- 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-expansion-panel-header>
|
||||||
<mat-panel-title>
|
<mat-panel-title>
|
||||||
<mat-icon>info</mat-icon>
|
<mat-icon>info</mat-icon>
|
||||||
@ -275,9 +275,27 @@
|
|||||||
<!-- Text -->
|
<!-- Text -->
|
||||||
<!-- TODO: this is a config variable. Read it out -->
|
<!-- TODO: this is a config variable. Read it out -->
|
||||||
<h3 translate>The assembly may decide:</h3>
|
<h3 translate>The assembly may decide:</h3>
|
||||||
<div *ngIf='motion && !editMotion'>
|
<ng-container *ngIf='motion && !editMotion'>
|
||||||
<div [innerHtml]='getFormatedText()'></div>
|
<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>
|
||||||
</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">
|
<mat-form-field *ngIf="motion && editMotion" class="wide-form">
|
||||||
<textarea matInput placeholder='Motion Text' formControlName='text' [value]='motionCopy.text'></textarea>
|
<textarea matInput placeholder='Motion Text' formControlName='text' [value]='motionCopy.text'></textarea>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
@ -160,3 +160,106 @@ mat-expansion-panel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Line numbers */
|
||||||
|
// :host ::ng-deep is needed as this styling applies to the motion html that is injected using innerHTML,
|
||||||
|
// 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,
|
||||||
|
.insert {
|
||||||
|
color: green;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
del,
|
||||||
|
.delete {
|
||||||
|
color: red;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol,
|
||||||
|
ul {
|
||||||
|
margin-left: 15px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight {
|
||||||
|
background-color: #ff0;
|
||||||
|
}
|
||||||
|
&.line-numbers-outside {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.line-numbers-inline {
|
||||||
|
.os-line-break {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.os-line-number {
|
||||||
|
display: inline-block;
|
||||||
|
|
||||||
|
&:after {
|
||||||
|
display: inline-block;
|
||||||
|
content: attr(data-line-number);
|
||||||
|
vertical-align: top;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: normal;
|
||||||
|
color: gray;
|
||||||
|
margin-top: -3px;
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.line-numbers-none {
|
||||||
|
.os-line-break {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.os-line-number {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -2,6 +2,7 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
|||||||
|
|
||||||
import { MotionDetailComponent } from './motion-detail.component';
|
import { MotionDetailComponent } from './motion-detail.component';
|
||||||
import { E2EImportsModule } from '../../../../../e2e-imports.module';
|
import { E2EImportsModule } from '../../../../../e2e-imports.module';
|
||||||
|
import { MotionsModule } from '../../motions.module';
|
||||||
|
|
||||||
describe('MotionDetailComponent', () => {
|
describe('MotionDetailComponent', () => {
|
||||||
let component: MotionDetailComponent;
|
let component: MotionDetailComponent;
|
||||||
@ -9,8 +10,8 @@ describe('MotionDetailComponent', () => {
|
|||||||
|
|
||||||
beforeEach(async(() => {
|
beforeEach(async(() => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [E2EImportsModule],
|
imports: [E2EImportsModule, MotionsModule],
|
||||||
declarations: [MotionDetailComponent]
|
declarations: []
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
@ -1,18 +1,27 @@
|
|||||||
import { Component, OnInit, ViewChild } from '@angular/core';
|
import { Component, OnInit, ViewChild } from '@angular/core';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { FormGroup, FormBuilder, Validators } from '@angular/forms';
|
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
import { MatExpansionPanel } from '@angular/material';
|
import { MatDialog, MatExpansionPanel } from '@angular/material';
|
||||||
|
|
||||||
import { BaseComponent } from '../../../../base.component';
|
import { BaseComponent } from '../../../../base.component';
|
||||||
import { Category } from '../../../../shared/models/motions/category';
|
import { Category } from '../../../../shared/models/motions/category';
|
||||||
import { ViewportService } from '../../../../core/services/viewport.service';
|
import { ViewportService } from '../../../../core/services/viewport.service';
|
||||||
import { MotionRepositoryService } from '../../services/motion-repository.service';
|
import { MotionRepositoryService } from '../../services/motion-repository.service';
|
||||||
import { ViewMotion } from '../../models/view-motion';
|
import { ChangeRecoMode, LineNumberingMode, ViewMotion } from '../../models/view-motion';
|
||||||
import { User } from '../../../../shared/models/users/user';
|
import { User } from '../../../../shared/models/users/user';
|
||||||
import { DataStoreService } from '../../../../core/services/data-store.service';
|
import { DataStoreService } from '../../../../core/services/data-store.service';
|
||||||
import { TranslateService } from '@ngx-translate/core';
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
import { Motion } from '../../../../shared/models/motions/motion';
|
import { Motion } from '../../../../shared/models/motions/motion';
|
||||||
import { BehaviorSubject } from 'rxjs';
|
import { BehaviorSubject } from 'rxjs';
|
||||||
|
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
|
* Component for the motion detail view
|
||||||
@ -67,6 +76,16 @@ export class MotionDetailComponent extends BaseComponent implements OnInit {
|
|||||||
*/
|
*/
|
||||||
public motionCopy: ViewMotion;
|
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
|
* Subject for the Categories
|
||||||
*/
|
*/
|
||||||
@ -82,6 +101,11 @@ export class MotionDetailComponent extends BaseComponent implements OnInit {
|
|||||||
*/
|
*/
|
||||||
public supporterObserver: BehaviorSubject<Array<User>>;
|
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.
|
* Constuct the detail view.
|
||||||
*
|
*
|
||||||
@ -89,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 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 route determine if this is a new or an existing motion
|
||||||
* @param formBuilder For reactive forms. Form Group and Form Control
|
* @param formBuilder For reactive forms. Form Group and Form Control
|
||||||
|
* @param dialogService For opening dialogs
|
||||||
* @param repo: Motion Repository
|
* @param repo: Motion Repository
|
||||||
|
* @param changeRecoRepo: Change Recommendation Repository
|
||||||
|
* @param DS: The DataStoreService
|
||||||
|
* @param sanitizer: For making HTML SafeHTML
|
||||||
* @param translate: Translation Service
|
* @param translate: Translation Service
|
||||||
*/
|
*/
|
||||||
public constructor(
|
public constructor(
|
||||||
@ -97,8 +125,11 @@ export class MotionDetailComponent extends BaseComponent implements OnInit {
|
|||||||
private router: Router,
|
private router: Router,
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private formBuilder: FormBuilder,
|
private formBuilder: FormBuilder,
|
||||||
|
private dialogService: MatDialog,
|
||||||
private repo: MotionRepositoryService,
|
private repo: MotionRepositoryService,
|
||||||
|
private changeRecoRepo: ChangeRecommendationRepositoryService,
|
||||||
private DS: DataStoreService,
|
private DS: DataStoreService,
|
||||||
|
private sanitizer: DomSanitizer,
|
||||||
protected translate: TranslateService
|
protected translate: TranslateService
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
@ -118,6 +149,12 @@ export class MotionDetailComponent extends BaseComponent implements OnInit {
|
|||||||
this.repo.getViewModelObservable(params.id).subscribe(newViewMotion => {
|
this.repo.getViewModelObservable(params.id).subscribe(newViewMotion => {
|
||||||
this.motion = newViewMotion;
|
this.motion = newViewMotion;
|
||||||
});
|
});
|
||||||
|
this.changeRecoRepo
|
||||||
|
.getChangeRecosOfMotionObservable(parseInt(params.id, 10))
|
||||||
|
.subscribe((recos: ViewChangeReco[]) => {
|
||||||
|
this.changeRecommendations = recos;
|
||||||
|
this.recalcUnifiedChanges();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Initial Filling of the Subjects
|
// Initial Filling of the Subjects
|
||||||
@ -137,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.
|
* Async load the values of the motion in the Form.
|
||||||
*/
|
*/
|
||||||
@ -215,8 +270,23 @@ export class MotionDetailComponent extends BaseComponent implements OnInit {
|
|||||||
/**
|
/**
|
||||||
* get the formated motion text from the repository.
|
* get the formated motion text from the repository.
|
||||||
*/
|
*/
|
||||||
public getFormatedText(): string {
|
public getFormattedTextPlain(): string {
|
||||||
return this.repo.formatMotion(this.motion.id, this.motion.lnMode, this.motion.crMode);
|
// 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());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -264,10 +334,31 @@ export class MotionDetailComponent extends BaseComponent implements OnInit {
|
|||||||
* Sets the motions line numbering mode
|
* Sets the motions line numbering mode
|
||||||
* @param mode Needs to fot to the enum defined in ViewMotion
|
* @param mode Needs to fot to the enum defined in ViewMotion
|
||||||
*/
|
*/
|
||||||
public setLineNumberingMode(mode: number): void {
|
public setLineNumberingMode(mode: LineNumberingMode): void {
|
||||||
this.motion.lnMode = mode;
|
this.motion.lnMode = mode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if no line numbers are to be shown.
|
||||||
|
*/
|
||||||
|
public isLineNumberingNone(): boolean {
|
||||||
|
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 === 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 === LineNumberingMode.Outside;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the motions change reco mode
|
* Sets the motions change reco mode
|
||||||
* @param mode Needs to fot to the enum defined in ViewMotion
|
* @param mode Needs to fot to the enum defined in ViewMotion
|
||||||
@ -276,6 +367,48 @@ export class MotionDetailComponent extends BaseComponent implements OnInit {
|
|||||||
this.motion.crMode = mode;
|
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.
|
* Init. Does nothing here.
|
||||||
*/
|
*/
|
||||||
|
100
client/src/app/site/motions/models/view-change-reco.ts
Normal file
100
client/src/app/site/motions/models/view-change-reco.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -6,15 +6,15 @@ import { WorkflowState } from '../../../shared/models/motions/workflow-state';
|
|||||||
import { BaseModel } from '../../../shared/models/base/base-model';
|
import { BaseModel } from '../../../shared/models/base/base-model';
|
||||||
import { BaseViewModel } from '../../base/base-view-model';
|
import { BaseViewModel } from '../../base/base-view-model';
|
||||||
|
|
||||||
enum LineNumbering {
|
export enum LineNumberingMode {
|
||||||
None,
|
None,
|
||||||
Inside,
|
Inside,
|
||||||
Outside
|
Outside
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ChangeReco {
|
export enum ChangeRecoMode {
|
||||||
Original,
|
Original,
|
||||||
Change,
|
Changed,
|
||||||
Diff,
|
Diff,
|
||||||
Final
|
Final
|
||||||
}
|
}
|
||||||
@ -35,16 +35,28 @@ export class ViewMotion extends BaseViewModel {
|
|||||||
private _state: WorkflowState;
|
private _state: WorkflowState;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Indicates the LineNumbering Mode.
|
* Indicates the LineNumberingMode Mode.
|
||||||
* Needs to be accessed from outside
|
* Needs to be accessed from outside
|
||||||
*/
|
*/
|
||||||
public lnMode: LineNumbering;
|
public lnMode: LineNumberingMode;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Indicates the Change reco Mode.
|
* Indicates the Change reco Mode.
|
||||||
* Needs to be accessed from outside
|
* Needs to be accessed from outside
|
||||||
*/
|
*/
|
||||||
public crMode: ChangeReco;
|
public crMode: ChangeRecoMode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates the maximum line length as defined in the configuration.
|
||||||
|
* Needs to be accessed from outside
|
||||||
|
*/
|
||||||
|
public lineLength: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates the currently highlighted line, if any.
|
||||||
|
* Needs to be accessed from outside
|
||||||
|
*/
|
||||||
|
public highlightedLine: number;
|
||||||
|
|
||||||
public get motion(): Motion {
|
public get motion(): Motion {
|
||||||
return this._motion;
|
return this._motion;
|
||||||
@ -177,8 +189,11 @@ export class ViewMotion extends BaseViewModel {
|
|||||||
this._state = state;
|
this._state = state;
|
||||||
|
|
||||||
// TODO: Should be set using a a config variable
|
// TODO: Should be set using a a config variable
|
||||||
this.lnMode = LineNumbering.None;
|
this.lnMode = LineNumberingMode.Outside;
|
||||||
this.crMode = ChangeReco.Original;
|
this.crMode = ChangeRecoMode.Original;
|
||||||
|
this.lineLength = 80;
|
||||||
|
|
||||||
|
this.highlightedLine = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getTitle(): string {
|
public getTitle(): string {
|
||||||
|
46
client/src/app/site/motions/models/view-unified-change.ts
Normal file
46
client/src/app/site/motions/models/view-unified-change.ts
Normal 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;
|
||||||
|
}
|
@ -8,6 +8,9 @@ import { MotionDetailComponent } from './components/motion-detail/motion-detail.
|
|||||||
import { CategoryListComponent } from './components/category-list/category-list.component';
|
import { CategoryListComponent } from './components/category-list/category-list.component';
|
||||||
import { MotionCommentSectionListComponent } from './components/motion-comment-section-list/motion-comment-section-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 { 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({
|
@NgModule({
|
||||||
imports: [CommonModule, MotionsRoutingModule, SharedModule],
|
imports: [CommonModule, MotionsRoutingModule, SharedModule],
|
||||||
@ -16,7 +19,12 @@ import { StatuteParagraphListComponent } from './components/statute-paragraph-li
|
|||||||
MotionDetailComponent,
|
MotionDetailComponent,
|
||||||
CategoryListComponent,
|
CategoryListComponent,
|
||||||
MotionCommentSectionListComponent,
|
MotionCommentSectionListComponent,
|
||||||
StatuteParagraphListComponent
|
StatuteParagraphListComponent,
|
||||||
]
|
MotionChangeRecommendationComponent,
|
||||||
|
MotionCommentSectionListComponent,
|
||||||
|
MotionDetailOriginalChangeRecommendationsComponent,
|
||||||
|
MotionDetailDiffComponent
|
||||||
|
],
|
||||||
|
entryComponents: [MotionChangeRecommendationComponent]
|
||||||
})
|
})
|
||||||
export class MotionsModule {}
|
export class MotionsModule {}
|
||||||
|
@ -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();
|
||||||
|
}
|
||||||
|
));
|
||||||
|
});
|
@ -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>;
|
||||||
|
}
|
||||||
|
}
|
1168
client/src/app/site/motions/services/diff.service.spec.ts
Normal file
1168
client/src/app/site/motions/services/diff.service.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
2031
client/src/app/site/motions/services/diff.service.ts
Normal file
2031
client/src/app/site/motions/services/diff.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,775 @@
|
|||||||
|
import { TestBed, inject } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { LinenumberingService } from './linenumbering.service';
|
||||||
|
|
||||||
|
describe('LinenumberingService', () => {
|
||||||
|
const brMarkup = (no: number): string => {
|
||||||
|
return (
|
||||||
|
'<br class="os-line-break">' +
|
||||||
|
'<span class="os-line-number line-number-' +
|
||||||
|
no +
|
||||||
|
'" data-line-number="' +
|
||||||
|
no +
|
||||||
|
'" contenteditable="false"> </span>'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
noMarkup = (no: number): string => {
|
||||||
|
return (
|
||||||
|
'<span class="os-line-number line-number-' +
|
||||||
|
no +
|
||||||
|
'" data-line-number="' +
|
||||||
|
no +
|
||||||
|
'" contenteditable="false"> </span>'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
longstr = (length: number): string => {
|
||||||
|
let outstr = '';
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
outstr += String.fromCharCode(65 + (i % 26));
|
||||||
|
}
|
||||||
|
return outstr;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [LinenumberingService]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', inject([LinenumberingService], (service: LinenumberingService) => {
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('paragraph splitting', () => {
|
||||||
|
it('breaks simple DIVs', inject([LinenumberingService], (service: LinenumberingService) => {
|
||||||
|
const htmlIn = '<DIV class="testclass">Test <strong>1</strong></DIV>' + '\n' + '<p>Test <em>2</em> 3</p>';
|
||||||
|
const out = service.splitToParagraphs(htmlIn);
|
||||||
|
expect(out.length).toBe(2);
|
||||||
|
expect(out[0]).toBe('<div class="testclass">Test <strong>1</strong></div>');
|
||||||
|
expect(out[1]).toBe('<p>Test <em>2</em> 3</p>');
|
||||||
|
}));
|
||||||
|
it('ignores root-level text-nodes', inject([LinenumberingService], (service: LinenumberingService) => {
|
||||||
|
const htmlIn = '<DIV class="testclass">Test <strong>3</strong></DIV>' + '\n New line';
|
||||||
|
const out = service.splitToParagraphs(htmlIn);
|
||||||
|
expect(out.length).toBe(1);
|
||||||
|
expect(out[0]).toBe('<div class="testclass">Test <strong>3</strong></div>');
|
||||||
|
}));
|
||||||
|
it('splits UL-Lists', inject([LinenumberingService], (service: LinenumberingService) => {
|
||||||
|
const htmlIn =
|
||||||
|
"<UL class='testclass'>\n<li>Node 1</li>\n <li class='second'>Node <strong>2</strong></li><li><p>Node 3</p></li></UL>";
|
||||||
|
const out = service.splitToParagraphs(htmlIn);
|
||||||
|
expect(out.length).toBe(3);
|
||||||
|
expect(out[0]).toBe('<ul class="testclass"><li>Node 1</li></ul>');
|
||||||
|
expect(out[1]).toBe('<ul class="testclass"><li class="second">Node <strong>2</strong></li></ul>');
|
||||||
|
expect(out[2]).toBe('<ul class="testclass"><li><p>Node 3</p></li></ul>');
|
||||||
|
}));
|
||||||
|
it('splits OL-Lists', inject([LinenumberingService], (service: LinenumberingService) => {
|
||||||
|
const htmlIn =
|
||||||
|
"<OL start='2' class='testclass'>\n<li>Node 1</li>\n <li class='second'>Node <strong>2</strong></li><li><p>Node 3</p></li></OL>";
|
||||||
|
const out = service.splitToParagraphs(htmlIn);
|
||||||
|
expect(out.length).toBe(3);
|
||||||
|
expect(out[0]).toBe('<ol start="2" class="testclass"><li>Node 1</li></ol>');
|
||||||
|
expect(out[1]).toBe('<ol start="3" class="testclass"><li class="second">Node <strong>2</strong></li></ol>');
|
||||||
|
expect(out[2]).toBe('<ol start="4" class="testclass"><li><p>Node 3</p></li></ol>');
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getting line number range', () => {
|
||||||
|
it('extracts the line number range, example 1', inject(
|
||||||
|
[LinenumberingService],
|
||||||
|
(service: LinenumberingService) => {
|
||||||
|
const html =
|
||||||
|
'<p>' +
|
||||||
|
noMarkup(2) +
|
||||||
|
'et accusam et justo duo dolores et ea <span style="color: #ff0000;"><strike>rebum </strike></span><span style="color: #006400;">Inserted Text</span>. Stet clita kasd ' +
|
||||||
|
brMarkup(3) +
|
||||||
|
'gubergren,</p>';
|
||||||
|
const range = service.getLineNumberRange(html);
|
||||||
|
expect(range).toEqual({ from: 2, to: 4 });
|
||||||
|
}
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('line numbering: test nodes', () => {
|
||||||
|
it('breaks very short lines', inject([LinenumberingService], (service: LinenumberingService) => {
|
||||||
|
const textNode = document.createTextNode('0123');
|
||||||
|
service.setInlineOffsetLineNumberForTests(0, 1);
|
||||||
|
const out = service.textNodeToLines(textNode, 5);
|
||||||
|
const outHtml = service.nodesToHtml(out);
|
||||||
|
expect(outHtml).toBe('0123');
|
||||||
|
expect(service.getInlineOffsetForTests()).toBe(4);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('breaks simple lines', inject([LinenumberingService], (service: LinenumberingService) => {
|
||||||
|
const textNode = document.createTextNode('012345678901234567');
|
||||||
|
service.setInlineOffsetLineNumberForTests(0, 1);
|
||||||
|
const out = service.textNodeToLines(textNode, 5);
|
||||||
|
const outHtml = service.nodesToHtml(out);
|
||||||
|
expect(outHtml).toBe('01234' + brMarkup(1) + '56789' + brMarkup(2) + '01234' + brMarkup(3) + '567');
|
||||||
|
expect(service.getInlineOffsetForTests()).toBe(3);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('breaks simple lines with offset', inject([LinenumberingService], (service: LinenumberingService) => {
|
||||||
|
const textNode = document.createTextNode('012345678901234567');
|
||||||
|
service.setInlineOffsetLineNumberForTests(2, 1);
|
||||||
|
const out = service.textNodeToLines(textNode, 5);
|
||||||
|
const outHtml = service.nodesToHtml(out);
|
||||||
|
expect(outHtml).toBe('012' + brMarkup(1) + '34567' + brMarkup(2) + '89012' + brMarkup(3) + '34567');
|
||||||
|
expect(service.getInlineOffsetForTests()).toBe(5);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('breaks simple lines with offset equaling to length', inject(
|
||||||
|
[LinenumberingService],
|
||||||
|
(service: LinenumberingService) => {
|
||||||
|
const textNode = document.createTextNode('012345678901234567');
|
||||||
|
service.setInlineOffsetLineNumberForTests(5, 1);
|
||||||
|
const out = service.textNodeToLines(textNode, 5);
|
||||||
|
const outHtml = service.nodesToHtml(out);
|
||||||
|
expect(outHtml).toBe(
|
||||||
|
brMarkup(1) + '01234' + brMarkup(2) + '56789' + brMarkup(3) + '01234' + brMarkup(4) + '567'
|
||||||
|
);
|
||||||
|
expect(service.getInlineOffsetForTests()).toBe(3);
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
it('breaks simple lines with spaces (1)', inject([LinenumberingService], (service: LinenumberingService) => {
|
||||||
|
const textNode = document.createTextNode('0123 45 67 89012 34 567');
|
||||||
|
service.setInlineOffsetLineNumberForTests(0, 1);
|
||||||
|
const out = service.textNodeToLines(textNode, 5);
|
||||||
|
const outHtml = service.nodesToHtml(out);
|
||||||
|
expect(outHtml).toBe(
|
||||||
|
'0123 ' + brMarkup(1) + '45 67 ' + brMarkup(2) + '89012 ' + brMarkup(3) + '34 ' + brMarkup(4) + '567'
|
||||||
|
);
|
||||||
|
expect(service.getInlineOffsetForTests()).toBe(3);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('breaks simple lines with spaces (2)', inject([LinenumberingService], (service: LinenumberingService) => {
|
||||||
|
const textNode = document.createTextNode('0123 45 67 89012tes 344 ');
|
||||||
|
service.setInlineOffsetLineNumberForTests(0, 1);
|
||||||
|
const out = service.textNodeToLines(textNode, 5);
|
||||||
|
const outHtml = service.nodesToHtml(out);
|
||||||
|
expect(outHtml).toBe(
|
||||||
|
'0123 ' + brMarkup(1) + '45 67 ' + brMarkup(2) + '89012' + brMarkup(3) + 'tes ' + brMarkup(4) + '344 '
|
||||||
|
);
|
||||||
|
expect(service.getInlineOffsetForTests()).toBe(4);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('breaks simple lines with spaces (3)', inject([LinenumberingService], (service: LinenumberingService) => {
|
||||||
|
const textNode = document.createTextNode("I'm a Demo-Text");
|
||||||
|
service.setInlineOffsetLineNumberForTests(0, 1);
|
||||||
|
const out = service.textNodeToLines(textNode, 5);
|
||||||
|
const outHtml = service.nodesToHtml(out);
|
||||||
|
expect(outHtml).toBe("I'm a " + brMarkup(1) + 'Demo-' + brMarkup(2) + 'Text');
|
||||||
|
expect(service.getInlineOffsetForTests()).toBe(4);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('breaks simple lines with spaces (4)', inject([LinenumberingService], (service: LinenumberingService) => {
|
||||||
|
const textNode = document.createTextNode("I'm a LongDemo-Text");
|
||||||
|
service.setInlineOffsetLineNumberForTests(0, 1);
|
||||||
|
const out = service.textNodeToLines(textNode, 5);
|
||||||
|
const outHtml = service.nodesToHtml(out);
|
||||||
|
expect(outHtml).toBe("I'm a " + brMarkup(1) + 'LongD' + brMarkup(2) + 'emo-' + brMarkup(3) + 'Text');
|
||||||
|
expect(service.getInlineOffsetForTests()).toBe(4);
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('line numbering: inline nodes', () => {
|
||||||
|
it('leaves a simple SPAN untouched', inject([LinenumberingService], (service: LinenumberingService) => {
|
||||||
|
const inHtml = '<span>Test</span>';
|
||||||
|
const outHtml = service.insertLineNumbers(inHtml, 5);
|
||||||
|
expect(outHtml).toBe(noMarkup(1) + '<span>Test</span>');
|
||||||
|
expect(service.stripLineNumbers(outHtml)).toBe(inHtml);
|
||||||
|
expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('breaks lines in a simple SPAN', inject([LinenumberingService], (service: LinenumberingService) => {
|
||||||
|
const inHtml = '<span>Lorem ipsum dolorsit amet</span>';
|
||||||
|
const outHtml = service.insertLineNumbers(inHtml, 5);
|
||||||
|
expect(outHtml).toBe(
|
||||||
|
noMarkup(1) +
|
||||||
|
'<span>Lorem ' +
|
||||||
|
brMarkup(2) +
|
||||||
|
'ipsum ' +
|
||||||
|
brMarkup(3) +
|
||||||
|
'dolor' +
|
||||||
|
brMarkup(4) +
|
||||||
|
'sit ' +
|
||||||
|
brMarkup(5) +
|
||||||
|
'amet</span>'
|
||||||
|
);
|
||||||
|
expect(service.stripLineNumbers(outHtml)).toBe(inHtml);
|
||||||
|
expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('breaks lines in nested inline elements', inject([LinenumberingService], (service: LinenumberingService) => {
|
||||||
|
const inHtml = '<span>Lorem <strong>ipsum dolorsit</strong> amet</span>';
|
||||||
|
const outHtml = service.insertLineNumbers(inHtml, 5);
|
||||||
|
expect(outHtml).toBe(
|
||||||
|
noMarkup(1) +
|
||||||
|
'<span>Lorem ' +
|
||||||
|
brMarkup(2) +
|
||||||
|
'<strong>ipsum ' +
|
||||||
|
brMarkup(3) +
|
||||||
|
'dolor' +
|
||||||
|
brMarkup(4) +
|
||||||
|
'sit</strong> ' +
|
||||||
|
brMarkup(5) +
|
||||||
|
'amet</span>'
|
||||||
|
);
|
||||||
|
expect(service.stripLineNumbers(outHtml)).toBe(inHtml);
|
||||||
|
expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('counts within DEL nodes', inject([LinenumberingService], (service: LinenumberingService) => {
|
||||||
|
const inHtml = '1234 <del>1234</del> 1234 1234';
|
||||||
|
const outHtml = service.insertLineNumbers(inHtml, 10);
|
||||||
|
expect(outHtml).toBe(noMarkup(1) + '1234 <del>1234</del> ' + brMarkup(2) + '1234 1234');
|
||||||
|
expect(service.stripLineNumbers(outHtml)).toBe(inHtml);
|
||||||
|
expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('counts after DEL/INS-nodes', inject([LinenumberingService], (service: LinenumberingService) => {
|
||||||
|
const inHtml =
|
||||||
|
'<P>leo Testelefantgeweih Buchstabenwut als Achzehnzahlunginer. Hierbei <DEL>darf</DEL><INS>setzen</INS> bist der Deifi <DEL>das </DEL><INS>Dor Reh Wachtel da </INS>Subjunktivier <DEL>als Derftige Aal</DEL><INS>san</INS> Orthopädische<DEL>, der Arbeitsnachweisdiskus Bass der Tastatur </DEL><DEL>Weiter schreiben wie Tasse Wasser als</DEL><INS> dienen</INS>.</P>';
|
||||||
|
const outHtml = service.insertLineNumbers(inHtml, 95);
|
||||||
|
expect(outHtml).toBe(
|
||||||
|
'<p>' +
|
||||||
|
noMarkup(1) +
|
||||||
|
'leo Testelefantgeweih Buchstabenwut als Achzehnzahlunginer. Hierbei <del>darf</del><ins>setzen</ins> bist der Deifi <del>das ' +
|
||||||
|
brMarkup(2) +
|
||||||
|
'</del><ins>Dor Reh Wachtel da </ins>Subjunktivier <del>als Derftige Aal</del><ins>san</ins> Orthopädische<del>, der Arbeitsnachweisdiskus Bass der Tastatur </del>' +
|
||||||
|
brMarkup(3) +
|
||||||
|
'<del>Weiter schreiben wie Tasse Wasser als</del><ins> dienen</ins>.</p>'
|
||||||
|
);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('handles STRIKE-tags', inject([LinenumberingService], (service: LinenumberingService) => {
|
||||||
|
const inHtml =
|
||||||
|
'<p>et accusam et justo duo dolores et ea <span style="color: #ff0000;"><strike>rebum </strike></span><span style="color: #006400;">Inserted Text</span>. Stet clita kasd gubergren,</p>';
|
||||||
|
const outHtml = service.insertLineNumbers(inHtml, 80);
|
||||||
|
expect(outHtml).toBe(
|
||||||
|
'<p>' +
|
||||||
|
noMarkup(1) +
|
||||||
|
'et accusam et justo duo dolores et ea <span style="color: #ff0000;"><strike>rebum </strike></span><span style="color: #006400;">Inserted Text</span>. Stet clita kasd ' +
|
||||||
|
brMarkup(2) +
|
||||||
|
'gubergren,</p>'
|
||||||
|
);
|
||||||
|
expect(service.stripLineNumbers(outHtml)).toBe(inHtml);
|
||||||
|
expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('treats ascii newline characters like spaces', inject(
|
||||||
|
[LinenumberingService],
|
||||||
|
(service: LinenumberingService) => {
|
||||||
|
const inHtml = '<p>Test 123\nTest1</p>';
|
||||||
|
const outHtml = service.insertLineNumbers(inHtml, 5);
|
||||||
|
expect(outHtml).toBe('<p>' + noMarkup(1) + 'Test ' + brMarkup(2) + '123\n' + brMarkup(3) + 'Test1</p>');
|
||||||
|
}
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('line numbering: block nodes', () => {
|
||||||
|
it('leaves a simple DIV untouched', inject([LinenumberingService], (service: LinenumberingService) => {
|
||||||
|
const inHtml = '<div>Test</div>';
|
||||||
|
const outHtml = service.insertLineNumbers(inHtml, 5);
|
||||||
|
expect(outHtml).toBe('<div>' + noMarkup(1) + 'Test</div>');
|
||||||
|
expect(service.stripLineNumbers(outHtml)).toBe(inHtml);
|
||||||
|
expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('breaks a DIV containing only inline elements', inject(
|
||||||
|
[LinenumberingService],
|
||||||
|
(service: LinenumberingService) => {
|
||||||
|
const inHtml = '<div>Test <span>Test1234</span>5678 Test</div>';
|
||||||
|
const outHtml = service.insertLineNumbers(inHtml, 5);
|
||||||
|
expect(outHtml).toBe(
|
||||||
|
'<div>' +
|
||||||
|
noMarkup(1) +
|
||||||
|
'Test ' +
|
||||||
|
brMarkup(2) +
|
||||||
|
'<span>Test1' +
|
||||||
|
brMarkup(3) +
|
||||||
|
'234</span>56' +
|
||||||
|
brMarkup(4) +
|
||||||
|
'78 ' +
|
||||||
|
brMarkup(5) +
|
||||||
|
'Test</div>'
|
||||||
|
);
|
||||||
|
expect(service.stripLineNumbers(outHtml)).toBe(inHtml);
|
||||||
|
expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml);
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
it('handles a DIV within a DIV correctly', inject([LinenumberingService], (service: LinenumberingService) => {
|
||||||
|
const inHtml = '<div>Te<div>Te Test</div>Test</div>';
|
||||||
|
const outHtml = service.insertLineNumbers(inHtml, 5);
|
||||||
|
expect(outHtml).toBe(
|
||||||
|
'<div>' +
|
||||||
|
noMarkup(1) +
|
||||||
|
'Te<div>' +
|
||||||
|
noMarkup(2) +
|
||||||
|
'Te ' +
|
||||||
|
brMarkup(3) +
|
||||||
|
'Test</div>' +
|
||||||
|
noMarkup(4) +
|
||||||
|
'Test</div>'
|
||||||
|
);
|
||||||
|
expect(service.stripLineNumbers(outHtml)).toBe(inHtml);
|
||||||
|
expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('ignores white spaces between block element tags', inject(
|
||||||
|
[LinenumberingService],
|
||||||
|
(service: LinenumberingService) => {
|
||||||
|
const inHtml = '<ul>\n<li>Test</li>\n</ul>';
|
||||||
|
const outHtml = service.insertLineNumbers(inHtml, 80);
|
||||||
|
expect(outHtml).toBe('<ul>\n<li>' + noMarkup(1) + 'Test</li>\n</ul>');
|
||||||
|
expect(service.stripLineNumbers(outHtml)).toBe(inHtml);
|
||||||
|
expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml);
|
||||||
|
}
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('indentation for block elements', () => {
|
||||||
|
it('indents LI-elements', inject([LinenumberingService], (service: LinenumberingService) => {
|
||||||
|
const inHtml = '<div>' + longstr(100) + '<ul><li>' + longstr(100) + '</li></ul>' + longstr(100) + '</div>';
|
||||||
|
const expected =
|
||||||
|
'<div>' +
|
||||||
|
noMarkup(1) +
|
||||||
|
'ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZAB' +
|
||||||
|
brMarkup(2) +
|
||||||
|
'CDEFGHIJKLMNOPQRSTUV' +
|
||||||
|
'<ul><li>' +
|
||||||
|
noMarkup(3) +
|
||||||
|
'ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVW' +
|
||||||
|
brMarkup(4) +
|
||||||
|
'XYZABCDEFGHIJKLMNOPQRSTUV' +
|
||||||
|
'</li></ul>' +
|
||||||
|
noMarkup(5) +
|
||||||
|
'ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZAB' +
|
||||||
|
brMarkup(6) +
|
||||||
|
'CDEFGHIJKLMNOPQRSTUV</div>';
|
||||||
|
const outHtml = service.insertLineNumbers(inHtml, 80);
|
||||||
|
expect(outHtml).toBe(expected);
|
||||||
|
expect(service.stripLineNumbers(outHtml)).toBe(inHtml);
|
||||||
|
expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('indents BLOCKQUOTE-elements', inject([LinenumberingService], (service: LinenumberingService) => {
|
||||||
|
const inHtml =
|
||||||
|
'<div>' + longstr(100) + '<blockquote>' + longstr(100) + '</blockquote>' + longstr(100) + '</div>';
|
||||||
|
const expected =
|
||||||
|
'<div>' +
|
||||||
|
noMarkup(1) +
|
||||||
|
'ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZAB' +
|
||||||
|
brMarkup(2) +
|
||||||
|
'CDEFGHIJKLMNOPQRSTUV' +
|
||||||
|
'<blockquote>' +
|
||||||
|
noMarkup(3) +
|
||||||
|
'ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGH' +
|
||||||
|
brMarkup(4) +
|
||||||
|
'IJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUV' +
|
||||||
|
'</blockquote>' +
|
||||||
|
noMarkup(5) +
|
||||||
|
'ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZAB' +
|
||||||
|
brMarkup(6) +
|
||||||
|
'CDEFGHIJKLMNOPQRSTUV</div>';
|
||||||
|
const outHtml = service.insertLineNumbers(inHtml, 80);
|
||||||
|
expect(outHtml).toBe(expected);
|
||||||
|
expect(service.stripLineNumbers(outHtml)).toBe(inHtml);
|
||||||
|
expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('shortens the line for H1-elements by 2/3', inject(
|
||||||
|
[LinenumberingService],
|
||||||
|
(service: LinenumberingService) => {
|
||||||
|
const inHtml = '<h1>' + longstr(80) + '</h1>';
|
||||||
|
const expected =
|
||||||
|
'<h1>' +
|
||||||
|
noMarkup(1) +
|
||||||
|
'ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZA' +
|
||||||
|
brMarkup(2) +
|
||||||
|
'BCDEFGHIJKLMNOPQRSTUVWXYZAB</h1>';
|
||||||
|
const outHtml = service.insertLineNumbers(inHtml, 80);
|
||||||
|
expect(outHtml).toBe(expected);
|
||||||
|
expect(service.stripLineNumbers(outHtml)).toBe(inHtml);
|
||||||
|
expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml);
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
it('shortens the line for H2-elements by 0.75', inject(
|
||||||
|
[LinenumberingService],
|
||||||
|
(service: LinenumberingService) => {
|
||||||
|
const inHtml = '<h2>' + longstr(80) + '</h2>';
|
||||||
|
const expected =
|
||||||
|
'<h2>' +
|
||||||
|
noMarkup(1) +
|
||||||
|
'ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGH' +
|
||||||
|
brMarkup(2) +
|
||||||
|
'IJKLMNOPQRSTUVWXYZAB</h2>';
|
||||||
|
const outHtml = service.insertLineNumbers(inHtml, 80);
|
||||||
|
expect(outHtml).toBe(expected);
|
||||||
|
expect(service.stripLineNumbers(outHtml)).toBe(inHtml);
|
||||||
|
expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml);
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
it('indents Ps with 30px-padding by 6 characters', inject(
|
||||||
|
[LinenumberingService],
|
||||||
|
(service: LinenumberingService) => {
|
||||||
|
const inHtml = '<div style="padding-left: 30px;">' + longstr(80) + '</div>';
|
||||||
|
const expected =
|
||||||
|
'<div style="padding-left: 30px;">' +
|
||||||
|
noMarkup(1) +
|
||||||
|
'ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUV' +
|
||||||
|
brMarkup(2) +
|
||||||
|
'WXYZAB</div>';
|
||||||
|
const outHtml = service.insertLineNumbers(inHtml, 80);
|
||||||
|
expect(outHtml).toBe(expected);
|
||||||
|
expect(service.stripLineNumbers(outHtml)).toBe(inHtml);
|
||||||
|
expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml);
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
it('breaks before an inline element, if the first word of the new inline element is longer than the remaining line (1)', inject(
|
||||||
|
[LinenumberingService],
|
||||||
|
(service: LinenumberingService) => {
|
||||||
|
const inHtml =
|
||||||
|
'<p>Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie <strong>consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio</strong>.</p>';
|
||||||
|
const outHtml = service.insertLineNumbers(inHtml, 80);
|
||||||
|
expect(outHtml).toBe(
|
||||||
|
'<p>' +
|
||||||
|
noMarkup(1) +
|
||||||
|
'Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie ' +
|
||||||
|
brMarkup(2) +
|
||||||
|
'<strong>consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan ' +
|
||||||
|
brMarkup(3) +
|
||||||
|
'et iusto odio</strong>.</p>'
|
||||||
|
);
|
||||||
|
expect(service.stripLineNumbers(outHtml)).toBe(inHtml);
|
||||||
|
expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml);
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
it('breaks before an inline element, if the first word of the new inline element is longer than the remaining line (2)', inject(
|
||||||
|
[LinenumberingService],
|
||||||
|
(service: LinenumberingService) => {
|
||||||
|
const inHtml =
|
||||||
|
'<p><span>Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie <strong>consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio</strong>.</span></p>';
|
||||||
|
const outHtml = service.insertLineNumbers(inHtml, 80);
|
||||||
|
expect(outHtml).toBe(
|
||||||
|
'<p>' +
|
||||||
|
noMarkup(1) +
|
||||||
|
'<span>Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie ' +
|
||||||
|
brMarkup(2) +
|
||||||
|
'<strong>consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan ' +
|
||||||
|
brMarkup(3) +
|
||||||
|
'et iusto odio</strong>.</span></p>'
|
||||||
|
);
|
||||||
|
expect(service.stripLineNumbers(outHtml)).toBe(inHtml);
|
||||||
|
expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml);
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
it('does not fail in a weird case', inject([LinenumberingService], (service: LinenumberingService) => {
|
||||||
|
const inHtml = '<ins>seid Noch</ins><p></p><p><ins>Test 123</ins></p>';
|
||||||
|
const outHtml = service.insertLineNumbers(inHtml, 80);
|
||||||
|
expect(outHtml).toBe(
|
||||||
|
noMarkup(1) + '<ins>seid Noch</ins><p></p><p>' + noMarkup(2) + '<ins>Test 123</ins></p>'
|
||||||
|
);
|
||||||
|
expect(service.stripLineNumbers(outHtml)).toBe(inHtml);
|
||||||
|
expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml);
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('line numbering in regard to the inline diff', () => {
|
||||||
|
it('does not count within INS nodes', inject([LinenumberingService], (service: LinenumberingService) => {
|
||||||
|
const inHtml = '1234 <ins>1234</ins> 1234 1234';
|
||||||
|
const outHtml = service.insertLineNumbers(inHtml, 10);
|
||||||
|
expect(outHtml).toBe(noMarkup(1) + '1234 <ins>1234</ins> 1234 ' + brMarkup(2) + '1234');
|
||||||
|
expect(service.stripLineNumbers(outHtml)).toBe(inHtml);
|
||||||
|
expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('does not create a new line for a trailing INS', inject(
|
||||||
|
[LinenumberingService],
|
||||||
|
(service: LinenumberingService) => {
|
||||||
|
const inHtml =
|
||||||
|
'<p>et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur<ins>dsfsdf23</ins></p>';
|
||||||
|
const outHtml = service.insertLineNumbers(inHtml, 80);
|
||||||
|
expect(outHtml).toBe(
|
||||||
|
'<p>' +
|
||||||
|
noMarkup(1) +
|
||||||
|
'et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata ' +
|
||||||
|
brMarkup(2) +
|
||||||
|
'sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur<ins>dsfsdf23</ins></p>'
|
||||||
|
);
|
||||||
|
expect(service.stripLineNumbers(outHtml)).toBe(inHtml);
|
||||||
|
expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml);
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
it('inserts the line number before the INS, if INS is the first element of the paragraph', inject(
|
||||||
|
[LinenumberingService],
|
||||||
|
(service: LinenumberingService) => {
|
||||||
|
const inHtml =
|
||||||
|
"<p><ins>lauthals </ins>'liebe Kinder, ich will hinaus in den Wald, seid auf der Hut vor dem Wolf!' Und noch etwas mehr Text bis zur nächsten Zeile</p>";
|
||||||
|
const outHtml = service.insertLineNumbers(inHtml, 80);
|
||||||
|
expect(outHtml).toBe(
|
||||||
|
'<p>' +
|
||||||
|
noMarkup(1) +
|
||||||
|
"<ins>lauthals </ins>'liebe Kinder, ich will hinaus in den Wald, seid auf der Hut vor dem Wolf!' Und " +
|
||||||
|
brMarkup(2) +
|
||||||
|
'noch etwas mehr Text bis zur nächsten Zeile</p>'
|
||||||
|
);
|
||||||
|
expect(service.stripLineNumbers(outHtml)).toBe(inHtml);
|
||||||
|
expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml);
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
it('cancels newlines after br-elements', inject([LinenumberingService], (service: LinenumberingService) => {
|
||||||
|
const inHtml = '<p>Test 123<br>\nTest 456</p>';
|
||||||
|
const outHtml = service.insertLineNumbers(inHtml, 80);
|
||||||
|
expect(outHtml).toBe('<p>' + noMarkup(1) + 'Test 123<br>' + noMarkup(2) + 'Test 456</p>');
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('does not force-break words right after an INS', inject(
|
||||||
|
[LinenumberingService],
|
||||||
|
(service: LinenumberingService) => {
|
||||||
|
const inHtml = '<p>' + noMarkup(1) + '012345 <ins>78 01 34567</ins>8901234567890123456789</p>';
|
||||||
|
const outHtml = service.insertLineBreaksWithoutNumbers(inHtml, 20, true);
|
||||||
|
expect(outHtml).toBe(
|
||||||
|
'<p>' +
|
||||||
|
noMarkup(1) +
|
||||||
|
'012345 <ins>78 01 <br class="os-line-break">34567</ins>890123456789012<br class="os-line-break">3456789</p>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('line breaking without adding line numbers', () => {
|
||||||
|
const plainBr = '<br class="os-line-break">';
|
||||||
|
|
||||||
|
it('breaks a DIV containing only inline elements', inject(
|
||||||
|
[LinenumberingService],
|
||||||
|
(service: LinenumberingService) => {
|
||||||
|
const inHtml = '<div>Test <span>Test1234</span>5678 Test</div>';
|
||||||
|
const outHtml = service.insertLineBreaksWithoutNumbers(inHtml, 5);
|
||||||
|
expect(outHtml).toBe(
|
||||||
|
'<div>Test ' +
|
||||||
|
plainBr +
|
||||||
|
'<span>Test1' +
|
||||||
|
plainBr +
|
||||||
|
'234</span>56' +
|
||||||
|
plainBr +
|
||||||
|
'78 ' +
|
||||||
|
plainBr +
|
||||||
|
'Test</div>'
|
||||||
|
);
|
||||||
|
expect(service.stripLineNumbers(outHtml)).toBe(inHtml);
|
||||||
|
expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml);
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
it('indents BLOCKQUOTE-elements', inject([LinenumberingService], (service: LinenumberingService) => {
|
||||||
|
const inHtml =
|
||||||
|
'<div>' + longstr(100) + '<blockquote>' + longstr(100) + '</blockquote>' + longstr(100) + '</div>';
|
||||||
|
const expected =
|
||||||
|
'<div>' +
|
||||||
|
'ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZAB' +
|
||||||
|
plainBr +
|
||||||
|
'CDEFGHIJKLMNOPQRSTUV' +
|
||||||
|
'<blockquote>' +
|
||||||
|
'ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGH' +
|
||||||
|
plainBr +
|
||||||
|
'IJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUV' +
|
||||||
|
'</blockquote>' +
|
||||||
|
'ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZAB' +
|
||||||
|
plainBr +
|
||||||
|
'CDEFGHIJKLMNOPQRSTUV</div>';
|
||||||
|
const outHtml = service.insertLineBreaksWithoutNumbers(inHtml, 80);
|
||||||
|
expect(outHtml).toBe(expected);
|
||||||
|
expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('DOES count within INS nodes', inject([LinenumberingService], (service: LinenumberingService) => {
|
||||||
|
const inHtml = '1234 <ins>1234</ins> 1234 1234';
|
||||||
|
const outHtml = service.insertLineBreaksWithoutNumbers(inHtml, 10, true);
|
||||||
|
expect(outHtml).toBe('1234 <ins>1234</ins> ' + plainBr + '1234 1234');
|
||||||
|
expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('does not create a new line for a trailing INS', inject(
|
||||||
|
[LinenumberingService],
|
||||||
|
(service: LinenumberingService) => {
|
||||||
|
const inHtml =
|
||||||
|
'<p>et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur<ins>dsfsdf23</ins></p>';
|
||||||
|
const outHtml = service.insertLineBreaksWithoutNumbers(inHtml, 80, true);
|
||||||
|
expect(outHtml).toBe(
|
||||||
|
'<p>et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata ' +
|
||||||
|
plainBr +
|
||||||
|
'sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur' +
|
||||||
|
plainBr +
|
||||||
|
'<ins>dsfsdf23</ins></p>'
|
||||||
|
);
|
||||||
|
expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml);
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
it('ignores witespaces by previously added line numbers', inject(
|
||||||
|
[LinenumberingService],
|
||||||
|
(service: LinenumberingService) => {
|
||||||
|
const inHtml = '<p>' + noMarkup(1) + longstr(10) + '</p>';
|
||||||
|
const outHtml = service.insertLineBreaksWithoutNumbers(inHtml, 10, true);
|
||||||
|
expect(outHtml).toBe('<p>' + noMarkup(1) + longstr(10) + '</p>');
|
||||||
|
expect(service.insertLineBreaksWithoutNumbers(outHtml, 80)).toBe(outHtml);
|
||||||
|
}
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('behavior regarding ckeditor', () => {
|
||||||
|
it('does not count empty lines, case 1', inject([LinenumberingService], (service: LinenumberingService) => {
|
||||||
|
const inHtml = '<p>Line 1</p>\n\n<p>Line 2</p>';
|
||||||
|
const outHtml = service.insertLineNumbers(inHtml, 80);
|
||||||
|
expect(outHtml).toBe('<p>' + noMarkup(1) + 'Line 1</p>' + '\n\n' + '<p>' + noMarkup(2) + 'Line 2</p>');
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('does not count empty lines, case 2', inject([LinenumberingService], (service: LinenumberingService) => {
|
||||||
|
const inHtml = '<ul>\n\n<li>Point 1</li>\n\n</ul>';
|
||||||
|
const outHtml = service.insertLineNumbers(inHtml, 80);
|
||||||
|
expect(outHtml).toBe('<ul>\n\n<li>' + noMarkup(1) + 'Point 1</li>\n\n</ul>');
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('line highlighting', () => {
|
||||||
|
it('highlights a simple line', inject([LinenumberingService], (service: LinenumberingService) => {
|
||||||
|
const inHtml = service.insertLineNumbers('<span>Lorem ipsum dolorsit amet</span>', 5);
|
||||||
|
const highlighted = service.highlightLine(inHtml, 2);
|
||||||
|
expect(highlighted).toBe(
|
||||||
|
noMarkup(1) +
|
||||||
|
'<span>Lorem ' +
|
||||||
|
brMarkup(2) +
|
||||||
|
'<span class="highlight">ipsum </span>' +
|
||||||
|
brMarkup(3) +
|
||||||
|
'dolor' +
|
||||||
|
brMarkup(4) +
|
||||||
|
'sit ' +
|
||||||
|
brMarkup(5) +
|
||||||
|
'amet</span>'
|
||||||
|
);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('highlights a simple line with formattings', inject(
|
||||||
|
[LinenumberingService],
|
||||||
|
(service: LinenumberingService) => {
|
||||||
|
const inHtml = service.insertLineNumbers(
|
||||||
|
'<span>Lorem ipsum <strong>dolorsit amet Lorem</strong><em> ipsum dolorsit amet</em> Lorem ipsum dolorsit amet</span>',
|
||||||
|
20
|
||||||
|
);
|
||||||
|
expect(inHtml).toBe(
|
||||||
|
noMarkup(1) +
|
||||||
|
'<span>Lorem ipsum <strong>dolorsit ' +
|
||||||
|
brMarkup(2) +
|
||||||
|
'amet Lorem</strong><em> ipsum ' +
|
||||||
|
brMarkup(3) +
|
||||||
|
'dolorsit amet</em> Lorem ' +
|
||||||
|
brMarkup(4) +
|
||||||
|
'ipsum dolorsit amet</span>'
|
||||||
|
);
|
||||||
|
|
||||||
|
const highlighted = service.highlightLine(inHtml, 2);
|
||||||
|
expect(highlighted).toBe(
|
||||||
|
noMarkup(1) +
|
||||||
|
'<span>Lorem ipsum <strong>dolorsit ' +
|
||||||
|
brMarkup(2) +
|
||||||
|
'<span class="highlight">amet Lorem</span></strong><em><span class="highlight"> ipsum </span>' +
|
||||||
|
brMarkup(3) +
|
||||||
|
'dolorsit amet</em> Lorem ' +
|
||||||
|
brMarkup(4) +
|
||||||
|
'ipsum dolorsit amet</span>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
it('highlights the last line', inject([LinenumberingService], (service: LinenumberingService) => {
|
||||||
|
const inHtml = service.insertLineNumbers('<span>Lorem ipsum dolorsit amet</span>', 5);
|
||||||
|
const highlighted = service.highlightLine(inHtml, 5);
|
||||||
|
expect(highlighted).toBe(
|
||||||
|
noMarkup(1) +
|
||||||
|
'<span>Lorem ' +
|
||||||
|
brMarkup(2) +
|
||||||
|
'ipsum ' +
|
||||||
|
brMarkup(3) +
|
||||||
|
'dolor' +
|
||||||
|
brMarkup(4) +
|
||||||
|
'sit ' +
|
||||||
|
brMarkup(5) +
|
||||||
|
'<span class="highlight">amet</span></span>'
|
||||||
|
);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('highlights the first line', inject([LinenumberingService], (service: LinenumberingService) => {
|
||||||
|
const inHtml = service.insertLineNumbers('<span>Lorem ipsum dolorsit amet</span>', 5);
|
||||||
|
const highlighted = service.highlightLine(inHtml, 1);
|
||||||
|
expect(highlighted).toBe(
|
||||||
|
noMarkup(1) +
|
||||||
|
'<span><span class="highlight">Lorem </span>' +
|
||||||
|
brMarkup(2) +
|
||||||
|
'ipsum ' +
|
||||||
|
brMarkup(3) +
|
||||||
|
'dolor' +
|
||||||
|
brMarkup(4) +
|
||||||
|
'sit ' +
|
||||||
|
brMarkup(5) +
|
||||||
|
'amet</span>'
|
||||||
|
);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('does not change the string if the line number is not found', inject(
|
||||||
|
[LinenumberingService],
|
||||||
|
(service: LinenumberingService) => {
|
||||||
|
const inHtml = service.insertLineNumbers('<span>Lorem ipsum dolorsit amet</span>', 5);
|
||||||
|
const highlighted = service.highlightLine(inHtml, 8);
|
||||||
|
expect(highlighted).toBe(
|
||||||
|
noMarkup(1) +
|
||||||
|
'<span>Lorem ' +
|
||||||
|
brMarkup(2) +
|
||||||
|
'ipsum ' +
|
||||||
|
brMarkup(3) +
|
||||||
|
'dolor' +
|
||||||
|
brMarkup(4) +
|
||||||
|
'sit ' +
|
||||||
|
brMarkup(5) +
|
||||||
|
'amet</span>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('document structure parsing', () => {
|
||||||
|
it('detects the line numbers of headings', inject([LinenumberingService], (service: LinenumberingService) => {
|
||||||
|
let inHtml =
|
||||||
|
'<p>Line 1</p>' +
|
||||||
|
'<h1>Heading 1</h1><p>Line 2</p><h2>Heading 1.1</h2><p>Line 3</p><h2>Heading 1.2</h2><p>Line 4</p>' +
|
||||||
|
'<h1>Heading 2</h1><h2>Heading 2.1</h2><p>Line 5</p>';
|
||||||
|
inHtml = service.insertLineNumbers(inHtml, 80);
|
||||||
|
const structure = service.getHeadingsWithLineNumbers(inHtml);
|
||||||
|
expect(structure).toEqual([
|
||||||
|
{ lineNumber: 2, level: 1, text: 'Heading 1' },
|
||||||
|
{ lineNumber: 4, level: 2, text: 'Heading 1.1' },
|
||||||
|
{ lineNumber: 6, level: 2, text: 'Heading 1.2' },
|
||||||
|
{ lineNumber: 8, level: 1, text: 'Heading 2' },
|
||||||
|
{ lineNumber: 9, level: 2, text: 'Heading 2.1' }
|
||||||
|
]);
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('caching', () => {
|
||||||
|
it('caches based on line length', inject([LinenumberingService], (service: LinenumberingService) => {
|
||||||
|
const inHtml = '<p>' + longstr(100) + '</p>';
|
||||||
|
const outHtml80 = service.insertLineNumbers(inHtml, 80);
|
||||||
|
const outHtml70 = service.insertLineNumbers(inHtml, 70);
|
||||||
|
expect(outHtml70).not.toBe(outHtml80);
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
1028
client/src/app/site/motions/services/linenumbering.service.ts
Normal file
1028
client/src/app/site/motions/services/linenumbering.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -6,10 +6,15 @@ import { User } from '../../../shared/models/users/user';
|
|||||||
import { Category } from '../../../shared/models/motions/category';
|
import { Category } from '../../../shared/models/motions/category';
|
||||||
import { Workflow } from '../../../shared/models/motions/workflow';
|
import { Workflow } from '../../../shared/models/motions/workflow';
|
||||||
import { WorkflowState } from '../../../shared/models/motions/workflow-state';
|
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 { Observable } from 'rxjs';
|
||||||
import { BaseRepository } from '../../base/base-repository';
|
import { BaseRepository } from '../../base/base-repository';
|
||||||
import { DataStoreService } from '../../../core/services/data-store.service';
|
import { DataStoreService } from '../../../core/services/data-store.service';
|
||||||
|
import { LinenumberingService } from './linenumbering.service';
|
||||||
|
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)
|
* Repository Services for motions (and potentially categories)
|
||||||
@ -30,9 +35,17 @@ export class MotionRepositoryService extends BaseRepository<ViewMotion, Motion>
|
|||||||
*
|
*
|
||||||
* Converts existing and incoming motions to ViewMotions
|
* Converts existing and incoming motions to ViewMotions
|
||||||
* Handles CRUD using an observer to the DataStore
|
* Handles CRUD using an observer to the DataStore
|
||||||
* @param DataSend
|
* @param {DataStoreService} DS
|
||||||
|
* @param {DataSendService} dataSend
|
||||||
|
* @param {LinenumberingService} lineNumbering
|
||||||
|
* @param {DiffService} diff
|
||||||
*/
|
*/
|
||||||
public constructor(DS: DataStoreService, private dataSend: DataSendService) {
|
public constructor(
|
||||||
|
DS: DataStoreService,
|
||||||
|
private dataSend: DataSendService,
|
||||||
|
private readonly lineNumbering: LinenumberingService,
|
||||||
|
private readonly diff: DiffService
|
||||||
|
) {
|
||||||
super(DS, Motion, [Category, User, Workflow]);
|
super(DS, Motion, [Category, User, Workflow]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -102,51 +115,209 @@ export class MotionRepositoryService extends BaseRepository<ViewMotion, Motion>
|
|||||||
* Format the motion text using the line numbering and change
|
* Format the motion text using the line numbering and change
|
||||||
* reco algorithm.
|
* reco algorithm.
|
||||||
*
|
*
|
||||||
* TODO: Call DiffView and LineNumbering Service here.
|
|
||||||
*
|
|
||||||
* Can be called from detail view and exporter
|
* Can be called from detail view and exporter
|
||||||
* @param id Motion ID - will be pulled from the repository
|
* @param id Motion ID - will be pulled from the repository
|
||||||
* @param lnMode indicator for the line numbering mode
|
|
||||||
* @param crMode indicator for the change reco mode
|
* @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, lnMode: number, crMode: number): string {
|
public formatMotion(
|
||||||
|
id: number,
|
||||||
|
crMode: ChangeRecoMode,
|
||||||
|
changes: ViewUnifiedChange[],
|
||||||
|
lineLength: number,
|
||||||
|
highlightLine?: number
|
||||||
|
): string {
|
||||||
const targetMotion = this.getViewModel(id);
|
const targetMotion = this.getViewModel(id);
|
||||||
|
|
||||||
if (targetMotion && targetMotion.text) {
|
if (targetMotion && targetMotion.text) {
|
||||||
let motionText = targetMotion.text;
|
|
||||||
|
|
||||||
// TODO : Use Line numbering service here
|
|
||||||
switch (lnMode) {
|
|
||||||
case 0: // no line numbers
|
|
||||||
break;
|
|
||||||
case 1: // line number inside
|
|
||||||
motionText = 'Get line numbers outside';
|
|
||||||
break;
|
|
||||||
case 2: // line number outside
|
|
||||||
motionText = 'Get line numbers inside';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO : Use Diff Service here.
|
|
||||||
// this will(currently) append the previous changes.
|
|
||||||
// update
|
|
||||||
switch (crMode) {
|
switch (crMode) {
|
||||||
case 0: // Original
|
case ChangeRecoMode.Original:
|
||||||
break;
|
return this.lineNumbering.insertLineNumbers(targetMotion.text, lineLength, highlightLine);
|
||||||
case 1: // Changed Version
|
case ChangeRecoMode.Changed:
|
||||||
motionText += ' and get changed version';
|
return this.diff.getTextWithChanges(targetMotion, changes, lineLength, highlightLine);
|
||||||
break;
|
case ChangeRecoMode.Diff:
|
||||||
case 2: // Diff Version
|
let text = '';
|
||||||
motionText += ' and get diff version';
|
changes.forEach((change: ViewUnifiedChange, idx: number) => {
|
||||||
break;
|
if (idx === 0) {
|
||||||
case 3: // Final Version
|
text += this.extractMotionLineRange(
|
||||||
motionText += ' and final version';
|
id,
|
||||||
break;
|
{
|
||||||
|
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 motionText;
|
|
||||||
} else {
|
} else {
|
||||||
return null;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user