diff --git a/client/src/app/shared/models/motions/motion-change-reco.ts b/client/src/app/shared/models/motions/motion-change-reco.ts index 24035b643..05aa22b51 100644 --- a/client/src/app/shared/models/motions/motion-change-reco.ts +++ b/client/src/app/shared/models/motions/motion-change-reco.ts @@ -6,7 +6,7 @@ import { BaseModel } from '../base/base-model'; */ export class MotionChangeReco extends BaseModel { public id: number; - public motion_version_id: number; + public motion_id: number; public rejected: boolean; public type: number; public other_description: string; diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts index c7d3e0a2e..753ece21f 100644 --- a/client/src/app/shared/shared.module.ts +++ b/client/src/app/shared/shared.module.ts @@ -20,10 +20,12 @@ import { MatDatepickerModule, MatNativeDateModule, DateAdapter, - MatIconModule + MatIconModule, + MatButtonToggleModule } from '@angular/material'; import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatChipsModule } from '@angular/material'; +import { MatRadioModule } from '@angular/material'; import { NgxMatSelectSearchModule } from 'ngx-mat-select-search'; import { MatDialogModule } from '@angular/material/dialog'; import { MatListModule } from '@angular/material/list'; @@ -88,6 +90,8 @@ import { PromptDialogComponent } from './components/prompt-dialog/prompt-dialog. // we either wait or include a fixed version manually (dirty) // https://github.com/google/material-design-icons/issues/786 MatIconModule, + MatRadioModule, + MatButtonToggleModule, TranslateModule.forChild(), RouterModule, NgxMatSelectSearchModule @@ -117,6 +121,8 @@ import { PromptDialogComponent } from './components/prompt-dialog/prompt-dialog. MatChipsModule, MatTooltipModule, MatIconModule, + MatRadioModule, + MatButtonToggleModule, NgxMatSelectSearchModule, TranslateModule, PermsDirective, diff --git a/client/src/app/site/motions/components/motion-change-recommendation/motion-change-recommendation.component.html b/client/src/app/site/motions/components/motion-change-recommendation/motion-change-recommendation.component.html new file mode 100644 index 000000000..70bc3529c --- /dev/null +++ b/client/src/app/site/motions/components/motion-change-recommendation/motion-change-recommendation.component.html @@ -0,0 +1,19 @@ +

Create a change recommendation

+ +
+

Change from line {{ lineRange.from }} to {{ lineRange.to }}:

+ + + {{ radio.title }} + + + + + +
+
+ + + + + diff --git a/client/src/app/site/motions/components/motion-change-recommendation/motion-change-recommendation.component.scss b/client/src/app/site/motions/components/motion-change-recommendation/motion-change-recommendation.component.scss new file mode 100644 index 000000000..e2963273e --- /dev/null +++ b/client/src/app/site/motions/components/motion-change-recommendation/motion-change-recommendation.component.scss @@ -0,0 +1,9 @@ +.wide-form { + textarea { + height: 100px; + } + + ::ng-deep { + width: 100%; + } +} diff --git a/client/src/app/site/motions/components/motion-change-recommendation/motion-change-recommendation.component.spec.ts b/client/src/app/site/motions/components/motion-change-recommendation/motion-change-recommendation.component.spec.ts new file mode 100644 index 000000000..e31dd8113 --- /dev/null +++ b/client/src/app/site/motions/components/motion-change-recommendation/motion-change-recommendation.component.spec.ts @@ -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; + + const changeReco = { + line_from: 1, + line_to: 2, + type: ModificationType.TYPE_REPLACEMENT, + text: '

', + 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(); + }); +}); diff --git a/client/src/app/site/motions/components/motion-change-recommendation/motion-change-recommendation.component.ts b/client/src/app/site/motions/components/motion-change-recommendation/motion-change-recommendation.component.ts new file mode 100644 index 000000000..179921432 --- /dev/null +++ b/client/src/app/site/motions/components/motion-change-recommendation/motion-change-recommendation.component.ts @@ -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 + ) { + 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 + } + }); + } + } +} diff --git a/client/src/app/site/motions/components/motion-detail-diff/motion-detail-diff.component.html b/client/src/app/site/motions/components/motion-detail-diff/motion-detail-diff.component.html new file mode 100644 index 000000000..76951821d --- /dev/null +++ b/client/src/app/site/motions/components/motion-detail-diff/motion-detail-diff.component.html @@ -0,0 +1,123 @@ + +

+ + {{ 'Summary of changes' | translate }}: + + + + + + +
+ {{ 'No change recommendations yet' | translate }} +
+
+ + + +
+
+
+ +
+ +
+
+ warning +
+
+ + + +
+
+ {{ 'Rejected' | translate }}: +
+ +
+
+
+ +
+ +
+
+ + + + + + + + + diff --git a/client/src/app/site/motions/components/motion-detail-diff/motion-detail-diff.component.scss b/client/src/app/site/motions/components/motion-detail-diff/motion-detail-diff.component.scss new file mode 100644 index 000000000..1fb3cfb1d --- /dev/null +++ b/client/src/app/site/motions/components/motion-detail-diff/motion-detail-diff.component.scss @@ -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; + } +} diff --git a/client/src/app/site/motions/components/motion-detail-diff/motion-detail-diff.component.spec.ts b/client/src/app/site/motions/components/motion-detail-diff/motion-detail-diff.component.spec.ts new file mode 100644 index 000000000..58deac193 --- /dev/null +++ b/client/src/app/site/motions/components/motion-detail-diff/motion-detail-diff.component.spec.ts @@ -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: ` + + ` +}) +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; + + 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(); + }); +}); diff --git a/client/src/app/site/motions/components/motion-detail-diff/motion-detail-diff.component.ts b/client/src/app/site/motions/components/motion-detail-diff/motion-detail-diff.component.ts new file mode 100644 index 000000000..9dfeb3802 --- /dev/null +++ b/client/src/app/site/motions/components/motion-detail-diff/motion-detail-diff.component.ts @@ -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 + * + * ``` + */ +@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 = new EventEmitter(); + + 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 '' + msg + ''; + } + 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 = 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); + } + } +} diff --git a/client/src/app/site/motions/components/motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component.html b/client/src/app/site/motions/components/motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component.html new file mode 100644 index 000000000..d3268a073 --- /dev/null +++ b/client/src/app/site/motions/components/motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component.html @@ -0,0 +1,12 @@ +
+
    +
  • +
diff --git a/client/src/app/site/motions/components/motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component.scss b/client/src/app/site/motions/components/motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component.scss new file mode 100644 index 000000000..47106c5a2 --- /dev/null +++ b/client/src/app/site/motions/components/motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component.scss @@ -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; + } +} diff --git a/client/src/app/site/motions/components/motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component.spec.ts b/client/src/app/site/motions/components/motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component.spec.ts new file mode 100644 index 000000000..7dba17e87 --- /dev/null +++ b/client/src/app/site/motions/components/motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component.spec.ts @@ -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: ` + + ` +}) +class TestHostComponent { + public html = '

Test123

'; + public changeRecommendations = []; + public createChangeRecommendation($event: Event): void {} + public gotoChangeRecommendation($event: Event): void {} +} + +describe('MotionDetailOriginalChangeRecommendationsComponent', () => { + let component: TestHostComponent; + let fixture: ComponentFixture; + + 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(); + }); +}); diff --git a/client/src/app/site/motions/components/motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component.ts b/client/src/app/site/motions/components/motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component.ts new file mode 100644 index 000000000..0dbd97f42 --- /dev/null +++ b/client/src/app/site/motions/components/motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component.ts @@ -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 + * + * ``` + */ +@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 = new EventEmitter(); + + @Output() + public gotoChangeRecommendation: EventEmitter = new EventEmitter(); + + @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 = ( + 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 = ( + this.element.querySelector('.os-line-number.line-number-' + reco.line_from.toString(10)) + ); + const to = 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 = this.el.nativeElement; + this.element = nativeElement.querySelector('.text'); + + this.renderer.listen(this.el.nativeElement, 'click', (ev: MouseEvent) => { + const 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 = 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(); + } +} diff --git a/client/src/app/site/motions/components/motion-detail/motion-detail.component.html b/client/src/app/site/motions/components/motion-detail/motion-detail.component.html index 76d4db5fd..98f17c7e9 100644 --- a/client/src/app/site/motions/components/motion-detail/motion-detail.component.html +++ b/client/src/app/site/motions/components/motion-detail/motion-detail.component.html @@ -50,7 +50,7 @@ - + info @@ -275,12 +275,27 @@

The assembly may decide:

-
-
-
+ +
+ +
+
+ +
diff --git a/client/src/app/site/motions/components/motion-detail/motion-detail.component.scss b/client/src/app/site/motions/components/motion-detail/motion-detail.component.scss index 5bd03e27b..554eaeb49 100644 --- a/client/src/app/site/motions/components/motion-detail/motion-detail.component.scss +++ b/client/src/app/site/motions/components/motion-detail/motion-detail.component.scss @@ -166,12 +166,14 @@ mat-expansion-panel { // which doesn't have the [ngcontent]-attributes necessary for regular styles. // An alternative approach (in case ::ng-deep gets removed) might be to change the view encapsulation. :host ::ng-deep .motion-text { - ins { + ins, + .insert { color: green; text-decoration: underline; } - del { + del, + .delete { color: red; text-decoration: line-through; } @@ -212,6 +214,20 @@ mat-expansion-panel { font-size: 12px; font-weight: normal; } + + &.selectable:hover:before, + &.selected:before { + position: absolute; + top: 4px; + left: 20px; + display: inline-block; + cursor: pointer; + content: ''; + width: 16px; + height: 16px; + background: url('data:image/svg+xml;utf8,'); + background-size: 16px 16px; + } } } diff --git a/client/src/app/site/motions/components/motion-detail/motion-detail.component.spec.ts b/client/src/app/site/motions/components/motion-detail/motion-detail.component.spec.ts index 717bfca45..067c3b9c7 100644 --- a/client/src/app/site/motions/components/motion-detail/motion-detail.component.spec.ts +++ b/client/src/app/site/motions/components/motion-detail/motion-detail.component.spec.ts @@ -2,6 +2,7 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { MotionDetailComponent } from './motion-detail.component'; import { E2EImportsModule } from '../../../../../e2e-imports.module'; +import { MotionsModule } from '../../motions.module'; describe('MotionDetailComponent', () => { let component: MotionDetailComponent; @@ -9,8 +10,8 @@ describe('MotionDetailComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - imports: [E2EImportsModule], - declarations: [MotionDetailComponent] + imports: [E2EImportsModule, MotionsModule], + declarations: [] }).compileComponents(); })); diff --git a/client/src/app/site/motions/components/motion-detail/motion-detail.component.ts b/client/src/app/site/motions/components/motion-detail/motion-detail.component.ts index 3996309fe..7a76be72f 100644 --- a/client/src/app/site/motions/components/motion-detail/motion-detail.component.ts +++ b/client/src/app/site/motions/components/motion-detail/motion-detail.component.ts @@ -1,19 +1,27 @@ import { Component, OnInit, ViewChild } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; -import { MatExpansionPanel } from '@angular/material'; +import { MatDialog, MatExpansionPanel } from '@angular/material'; import { BaseComponent } from '../../../../base.component'; import { Category } from '../../../../shared/models/motions/category'; import { ViewportService } from '../../../../core/services/viewport.service'; import { MotionRepositoryService } from '../../services/motion-repository.service'; -import { LineNumbering, ViewMotion } from '../../models/view-motion'; +import { ChangeRecoMode, LineNumberingMode, ViewMotion } from '../../models/view-motion'; import { User } from '../../../../shared/models/users/user'; import { DataStoreService } from '../../../../core/services/data-store.service'; import { TranslateService } from '@ngx-translate/core'; import { Motion } from '../../../../shared/models/motions/motion'; import { BehaviorSubject } from 'rxjs'; -import { SafeHtml } from '@angular/platform-browser'; +import { LineRange } from '../../services/diff.service'; +import { + MotionChangeRecommendationComponent, + MotionChangeRecommendationComponentData +} from '../motion-change-recommendation/motion-change-recommendation.component'; +import { ChangeRecommendationRepositoryService } from '../../services/change-recommendation-repository.service'; +import { ViewChangeReco } from '../../models/view-change-reco'; +import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; +import { ViewUnifiedChange } from '../../models/view-unified-change'; /** * Component for the motion detail view @@ -68,6 +76,16 @@ export class MotionDetailComponent extends BaseComponent implements OnInit { */ public motionCopy: ViewMotion; + /** + * All change recommendations to this motion + */ + public changeRecommendations: ViewChangeReco[]; + + /** + * All change recommendations AND amendments, sorted by line number. + */ + public allChangingObjects: ViewUnifiedChange[]; + /** * Subject for the Categories */ @@ -83,6 +101,11 @@ export class MotionDetailComponent extends BaseComponent implements OnInit { */ public supporterObserver: BehaviorSubject>; + /** + * Value for os-motion-detail-diff: when this is set, that component scrolls to the given change + */ + public scrollToChange: ViewUnifiedChange = null; + /** * Constuct the detail view. * @@ -90,7 +113,11 @@ export class MotionDetailComponent extends BaseComponent implements OnInit { * @param router to navigate back to the motion list and to an existing motion * @param route determine if this is a new or an existing motion * @param formBuilder For reactive forms. Form Group and Form Control + * @param dialogService For opening dialogs * @param repo: Motion Repository + * @param changeRecoRepo: Change Recommendation Repository + * @param DS: The DataStoreService + * @param sanitizer: For making HTML SafeHTML * @param translate: Translation Service */ public constructor( @@ -98,8 +125,11 @@ export class MotionDetailComponent extends BaseComponent implements OnInit { private router: Router, private route: ActivatedRoute, private formBuilder: FormBuilder, + private dialogService: MatDialog, private repo: MotionRepositoryService, + private changeRecoRepo: ChangeRecommendationRepositoryService, private DS: DataStoreService, + private sanitizer: DomSanitizer, protected translate: TranslateService ) { super(); @@ -119,6 +149,12 @@ export class MotionDetailComponent extends BaseComponent implements OnInit { this.repo.getViewModelObservable(params.id).subscribe(newViewMotion => { this.motion = newViewMotion; }); + this.changeRecoRepo + .getChangeRecosOfMotionObservable(parseInt(params.id, 10)) + .subscribe((recos: ViewChangeReco[]) => { + this.changeRecommendations = recos; + this.recalcUnifiedChanges(); + }); }); } // Initial Filling of the Subjects @@ -138,6 +174,24 @@ export class MotionDetailComponent extends BaseComponent implements OnInit { }); } + /** + * Merges amendments and change recommendations and sorts them by the line numbers. + * Called each time one of these arrays changes. + */ + private recalcUnifiedChanges(): void { + // @TODO implement amendments + this.allChangingObjects = this.changeRecommendations; + this.allChangingObjects.sort((a: ViewUnifiedChange, b: ViewUnifiedChange) => { + if (a.getLineFrom() < b.getLineFrom()) { + return -1; + } else if (a.getLineFrom() > b.getLineFrom()) { + return 1; + } else { + return 0; + } + }); + } + /** * Async load the values of the motion in the Form. */ @@ -216,15 +270,25 @@ export class MotionDetailComponent extends BaseComponent implements OnInit { /** * get the formated motion text from the repository. */ - public getFormattedText(): SafeHtml { + public getFormattedTextPlain(): string { + // Prevent this.allChangingObjects to be reordered from within formatMotion + const changes: ViewUnifiedChange[] = Object.assign([], this.allChangingObjects); return this.repo.formatMotion( this.motion.id, this.motion.crMode, + changes, this.motion.lineLength, this.motion.highlightedLine ); } + /** + * get the formated motion text from the repository, as SafeHTML for [innerHTML] + */ + public getFormattedText(): SafeHtml { + return this.sanitizer.bypassSecurityTrustHtml(this.getFormattedTextPlain()); + } + /** * Click on the edit button (pen-symbol) */ @@ -270,7 +334,7 @@ export class MotionDetailComponent extends BaseComponent implements OnInit { * Sets the motions line numbering mode * @param mode Needs to fot to the enum defined in ViewMotion */ - public setLineNumberingMode(mode: LineNumbering): void { + public setLineNumberingMode(mode: LineNumberingMode): void { this.motion.lnMode = mode; } @@ -278,21 +342,21 @@ export class MotionDetailComponent extends BaseComponent implements OnInit { * Returns true if no line numbers are to be shown. */ public isLineNumberingNone(): boolean { - return this.motion.lnMode === LineNumbering.None; + return this.motion.lnMode === LineNumberingMode.None; } /** * Returns true if the line numbers are to be shown within the text with no line breaks. */ public isLineNumberingInline(): boolean { - return this.motion.lnMode === LineNumbering.Inside; + return this.motion.lnMode === LineNumberingMode.Inside; } /** * Returns true if the line numbers are to be shown to the left of the text. */ public isLineNumberingOutside(): boolean { - return this.motion.lnMode === LineNumbering.Outside; + return this.motion.lnMode === LineNumberingMode.Outside; } /** @@ -303,6 +367,48 @@ export class MotionDetailComponent extends BaseComponent implements OnInit { this.motion.crMode = mode; } + /** + * Returns true if the original version (including change recommendation annotation) is to be shown + */ + public isRecoModeOriginal(): boolean { + return this.motion.crMode === ChangeRecoMode.Original; + } + + /** + * Returns true if the diff version is to be shown + */ + public isRecoModeDiff(): boolean { + return this.motion.crMode === ChangeRecoMode.Diff; + } + + /** + * In the original version, a line number range has been selected in order to create a new change recommendation + * + * @param lineRange + */ + public createChangeRecommendation(lineRange: LineRange): void { + const data: MotionChangeRecommendationComponentData = { + editChangeRecommendation: false, + newChangeRecommendation: true, + lineRange: lineRange, + changeRecommendation: this.repo.createChangeRecommendationTemplate(this.motion.id, lineRange) + }; + this.dialogService.open(MotionChangeRecommendationComponent, { + height: '400px', + width: '600px', + data: data + }); + } + + /** + * In the original version, a change-recommendation-annotation has been clicked + * -> Go to the diff view and scroll to the change recommendation + */ + public gotoChangeRecommendation(changeRecommendation: ViewChangeReco): void { + this.scrollToChange = changeRecommendation; + this.setChangeRecoMode(ChangeRecoMode.Diff); + } + /** * Init. Does nothing here. */ diff --git a/client/src/app/site/motions/models/view-change-reco.ts b/client/src/app/site/motions/models/view-change-reco.ts new file mode 100644 index 000000000..a0f5ab9cc --- /dev/null +++ b/client/src/app/site/motions/models/view-change-reco.ts @@ -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; + } +} diff --git a/client/src/app/site/motions/models/view-motion.ts b/client/src/app/site/motions/models/view-motion.ts index a765af708..9efe71253 100644 --- a/client/src/app/site/motions/models/view-motion.ts +++ b/client/src/app/site/motions/models/view-motion.ts @@ -6,15 +6,15 @@ import { WorkflowState } from '../../../shared/models/motions/workflow-state'; import { BaseModel } from '../../../shared/models/base/base-model'; import { BaseViewModel } from '../../base/base-view-model'; -export enum LineNumbering { +export enum LineNumberingMode { None, Inside, Outside } -enum ChangeReco { +export enum ChangeRecoMode { Original, - Change, + Changed, Diff, Final } @@ -35,16 +35,16 @@ export class ViewMotion extends BaseViewModel { private _state: WorkflowState; /** - * Indicates the LineNumbering Mode. + * Indicates the LineNumberingMode Mode. * Needs to be accessed from outside */ - public lnMode: LineNumbering; + public lnMode: LineNumberingMode; /** * Indicates the Change reco Mode. * Needs to be accessed from outside */ - public crMode: ChangeReco; + public crMode: ChangeRecoMode; /** * Indicates the maximum line length as defined in the configuration. @@ -189,8 +189,8 @@ export class ViewMotion extends BaseViewModel { this._state = state; // TODO: Should be set using a a config variable - this.lnMode = LineNumbering.None; - this.crMode = ChangeReco.Original; + this.lnMode = LineNumberingMode.Outside; + this.crMode = ChangeRecoMode.Original; this.lineLength = 80; this.highlightedLine = null; diff --git a/client/src/app/site/motions/models/view-unified-change.ts b/client/src/app/site/motions/models/view-unified-change.ts new file mode 100644 index 000000000..2875b3b0f --- /dev/null +++ b/client/src/app/site/motions/models/view-unified-change.ts @@ -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; +} diff --git a/client/src/app/site/motions/motions.module.ts b/client/src/app/site/motions/motions.module.ts index 67319fcf4..fa15aead1 100644 --- a/client/src/app/site/motions/motions.module.ts +++ b/client/src/app/site/motions/motions.module.ts @@ -8,6 +8,9 @@ import { MotionDetailComponent } from './components/motion-detail/motion-detail. import { CategoryListComponent } from './components/category-list/category-list.component'; import { MotionCommentSectionListComponent } from './components/motion-comment-section-list/motion-comment-section-list.component'; import { StatuteParagraphListComponent } from './components/statute-paragraph-list/statute-paragraph-list.component'; +import { MotionChangeRecommendationComponent } from './components/motion-change-recommendation/motion-change-recommendation.component'; +import { MotionDetailOriginalChangeRecommendationsComponent } from './components/motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component'; +import { MotionDetailDiffComponent } from './components/motion-detail-diff/motion-detail-diff.component'; @NgModule({ imports: [CommonModule, MotionsRoutingModule, SharedModule], @@ -16,7 +19,12 @@ import { StatuteParagraphListComponent } from './components/statute-paragraph-li MotionDetailComponent, CategoryListComponent, MotionCommentSectionListComponent, - StatuteParagraphListComponent - ] + StatuteParagraphListComponent, + MotionChangeRecommendationComponent, + MotionCommentSectionListComponent, + MotionDetailOriginalChangeRecommendationsComponent, + MotionDetailDiffComponent + ], + entryComponents: [MotionChangeRecommendationComponent] }) export class MotionsModule {} diff --git a/client/src/app/site/motions/services/change-recommendation-repository.service.spec.ts b/client/src/app/site/motions/services/change-recommendation-repository.service.spec.ts new file mode 100644 index 000000000..2bb662d6f --- /dev/null +++ b/client/src/app/site/motions/services/change-recommendation-repository.service.spec.ts @@ -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(); + } + )); +}); diff --git a/client/src/app/site/motions/services/change-recommendation-repository.service.ts b/client/src/app/site/motions/services/change-recommendation-repository.service.ts new file mode 100644 index 000000000..e71932a12 --- /dev/null +++ b/client/src/app/site/motions/services/change-recommendation-repository.service.ts @@ -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 { + /** + * 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 { + return this.dataSend.createModel(changeReco) as Observable; + } + + /** + * 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 { + 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 { + return this.dataSend.delete(viewModel.changeRecommendation) as Observable; + } + + /** + * updates a change recommendation + * + * Updates a (real) change recommendation with patched data and delegate it + * to the {@link DataSendService} + * + * @param {Partial} 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, viewModel: ViewChangeReco): Observable { + const changeReco = viewModel.changeRecommendation; + changeReco.patchValues(update); + return this.dataSend.updateModel(changeReco, 'patch') as Observable; + } + + /** + * return the Observable of all change recommendations belonging to the given motion + */ + public getChangeRecosOfMotionObservable(motion_id: number): Observable { + 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 { + const changeReco = change.changeRecommendation; + changeReco.patchValues({ + rejected: false + }); + return this.dataSend.updateModel(changeReco, 'patch') as Observable; + } + + /** + * Sets a change recommendation to rejected. + * + * @param {ViewChangeReco} change + */ + public setRejected(change: ViewChangeReco): Observable { + const changeReco = change.changeRecommendation; + changeReco.patchValues({ + rejected: true + }); + return this.dataSend.updateModel(changeReco, 'patch') as Observable; + } +} diff --git a/client/src/app/site/motions/services/diff.service.ts b/client/src/app/site/motions/services/diff.service.ts index 0a034beaf..39a2ae4ea 100644 --- a/client/src/app/site/motions/services/diff.service.ts +++ b/client/src/app/site/motions/services/diff.service.ts @@ -1,5 +1,7 @@ import { Injectable } from '@angular/core'; import { LinenumberingService } from './linenumbering.service'; +import { ViewMotion } from '../models/view-motion'; +import { ViewUnifiedChange } from '../models/view-unified-change'; const ELEMENT_NODE = 1; const TEXT_NODE = 3; @@ -99,7 +101,7 @@ interface ExtractedContent { /** * An object specifying a range of line numbers. */ -interface LineRange { +export interface LineRange { /** * The first line number to be included. */ @@ -173,10 +175,16 @@ interface LineRange { providedIn: 'root' }) export class DiffService { + // @TODO Decide on a more sophisticated implementation private diffCache = { - get: (key: string) => undefined, - put: (key: string, val: any) => undefined - }; // @TODO + _cache: {}, + get: (key: string): any => { + return this.diffCache._cache[key] === undefined ? null : this.diffCache._cache[key]; + }, + put: (key: string, val: any): void => { + this.diffCache._cache[key] = val; + } + }; /** * Creates the DiffService. @@ -1983,4 +1991,41 @@ export class DiffService { this.diffCache.put(cacheKey, diff); return diff; } + + /** + * Applies all given changes to the motion and returns the (line-numbered) text + * + * @param {ViewMotion} motion + * @param {ViewUnifiedChange[]} changes + * @param {number} lineLength + * @param {number} highlightLine + */ + public getTextWithChanges( + motion: ViewMotion, + changes: ViewUnifiedChange[], + lineLength: number, + highlightLine: number + ): string { + let html = motion.text; + + // Changes need to be applied from the bottom up, to prevent conflicts with changing line numbers. + changes.sort((change1: ViewUnifiedChange, change2: ViewUnifiedChange) => { + if (change1.getLineFrom() < change2.getLineFrom()) { + return 1; + } else if (change1.getLineFrom() > change2.getLineFrom()) { + return -1; + } else { + return 0; + } + }); + + changes.forEach((change: ViewUnifiedChange) => { + html = this.lineNumberingService.insertLineNumbers(html, lineLength, null, null, 1); + html = this.replaceLines(html, change.getChangeNewText(), change.getLineFrom(), change.getLineTo()); + }); + + html = this.lineNumberingService.insertLineNumbers(html, lineLength, highlightLine, null, 1); + + return html; + } } diff --git a/client/src/app/site/motions/services/linenumbering.service.ts b/client/src/app/site/motions/services/linenumbering.service.ts index 9e79dc613..d5af49ade 100644 --- a/client/src/app/site/motions/services/linenumbering.service.ts +++ b/client/src/app/site/motions/services/linenumbering.service.ts @@ -90,14 +90,19 @@ interface SectionHeading { }) export class LinenumberingService { /** - * @TODO + * @TODO Decide on a more sophisticated implementation * This is just a stub for a caching system. The original code from Angular1 was: * var lineNumberCache = $cacheFactory('linenumbering.service'); * This should be replaced by a real cache once we have decided on a caching service for OpenSlides 3 */ private lineNumberCache = { - get: (key: string) => undefined, - put: (key: string, val: any) => undefined + _cache: {}, + get: (key: string): any => { + return this.lineNumberCache._cache[key] === undefined ? null : this.lineNumberCache._cache[key]; + }, + put: (key: string, val: any): void => { + this.lineNumberCache._cache[key] = val; + } }; // Counts the number of characters in the current line, beyond singe nodes. diff --git a/client/src/app/site/motions/services/motion-repository.service.ts b/client/src/app/site/motions/services/motion-repository.service.ts index c502c1e9d..4bc0e07dc 100644 --- a/client/src/app/site/motions/services/motion-repository.service.ts +++ b/client/src/app/site/motions/services/motion-repository.service.ts @@ -6,12 +6,15 @@ import { User } from '../../../shared/models/users/user'; import { Category } from '../../../shared/models/motions/category'; import { Workflow } from '../../../shared/models/motions/workflow'; import { WorkflowState } from '../../../shared/models/motions/workflow-state'; -import { ViewMotion } from '../models/view-motion'; +import { ChangeRecoMode, ViewMotion } from '../models/view-motion'; import { Observable } from 'rxjs'; import { BaseRepository } from '../../base/base-repository'; import { DataStoreService } from '../../../core/services/data-store.service'; import { LinenumberingService } from './linenumbering.service'; -import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; +import { DiffService, LineRange, ModificationType } from './diff.service'; +import { ViewChangeReco } from '../models/view-change-reco'; +import { MotionChangeReco } from '../../../shared/models/motions/motion-change-reco'; +import { ViewUnifiedChange } from '../models/view-unified-change'; /** * Repository Services for motions (and potentially categories) @@ -32,15 +35,16 @@ export class MotionRepositoryService extends BaseRepository * * Converts existing and incoming motions to ViewMotions * Handles CRUD using an observer to the DataStore - * @param DS - * @param dataSend - * @param lineNumbering + * @param {DataStoreService} DS + * @param {DataSendService} dataSend + * @param {LinenumberingService} lineNumbering + * @param {DiffService} diff */ public constructor( DS: DataStoreService, private dataSend: DataSendService, private readonly lineNumbering: LinenumberingService, - private readonly sanitizer: DomSanitizer + private readonly diff: DiffService ) { super(DS, Motion, [Category, User, Workflow]); } @@ -111,41 +115,209 @@ export class MotionRepositoryService extends BaseRepository * Format the motion text using the line numbering and change * reco algorithm. * - * TODO: Call DiffView Service here. - * * Can be called from detail view and exporter * @param id Motion ID - will be pulled from the repository * @param crMode indicator for the change reco mode + * @param changes all change recommendations and amendments, sorted by line number * @param lineLength the current line * @param highlightLine the currently highlighted line (default: none) */ - public formatMotion(id: number, crMode: number, lineLength: number, highlightLine?: number): SafeHtml { + public formatMotion( + id: number, + crMode: ChangeRecoMode, + changes: ViewUnifiedChange[], + lineLength: number, + highlightLine?: number + ): string { const targetMotion = this.getViewModel(id); if (targetMotion && targetMotion.text) { - let motionText = targetMotion.text; - motionText = this.lineNumbering.insertLineNumbers(motionText, lineLength, highlightLine); - - // TODO : Use Diff Service here. - // this will(currently) append the previous changes. - // update switch (crMode) { - case 0: // Original - break; - case 1: // Changed Version - motionText += ' and get changed version'; - break; - case 2: // Diff Version - motionText += ' and get diff version'; - break; - case 3: // Final Version - motionText += ' and final version'; - break; + case ChangeRecoMode.Original: + return this.lineNumbering.insertLineNumbers(targetMotion.text, lineLength, highlightLine); + case ChangeRecoMode.Changed: + return this.diff.getTextWithChanges(targetMotion, changes, lineLength, highlightLine); + case ChangeRecoMode.Diff: + let text = ''; + changes.forEach((change: ViewUnifiedChange, idx: number) => { + if (idx === 0) { + text += this.extractMotionLineRange( + id, + { + from: 1, + to: change.getLineFrom() + }, + true + ); + } else if (changes[idx - 1].getLineTo() < change.getLineFrom()) { + text += this.extractMotionLineRange( + id, + { + from: changes[idx - 1].getLineTo(), + to: change.getLineFrom() + }, + true + ); + } + text += this.getChangeDiff(targetMotion, change, highlightLine); + }); + text += this.getTextRemainderAfterLastChange(targetMotion, changes, highlightLine); + return text; + case ChangeRecoMode.Final: + const appliedChanges: ViewUnifiedChange[] = changes.filter(change => change.isAccepted()); + return this.diff.getTextWithChanges(targetMotion, appliedChanges, lineLength, highlightLine); + default: + console.error('unrecognized ChangeRecoMode option'); + return null; } - - return this.sanitizer.bypassSecurityTrustHtml(motionText); } else { return null; } } + + /** + * Extracts a renderable HTML string representing the given line number range of this motion + * + * @param {number} id + * @param {LineRange} lineRange + * @param {boolean} lineNumbers - weather to add line numbers to the returned HTML string + */ + public extractMotionLineRange(id: number, lineRange: LineRange, lineNumbers: boolean): string { + // @TODO flexible line numbers + const origHtml = this.formatMotion(id, ChangeRecoMode.Original, [], 80); + const extracted = this.diff.extractRangeByLineNumbers(origHtml, lineRange.from, lineRange.to); + let html = + extracted.outerContextStart + + extracted.innerContextStart + + extracted.html + + extracted.innerContextEnd + + extracted.outerContextEnd; + if (lineNumbers) { + html = this.lineNumbering.insertLineNumbers(html, 80, null, null, lineRange.from); + } + return html; + } + + /** + * Returns the remainder text of the motion after the last change + * + * @param {ViewMotion} motion + * @param {ViewUnifiedChange[]} changes + * @param {number} highlight + */ + public getTextRemainderAfterLastChange( + motion: ViewMotion, + changes: ViewUnifiedChange[], + highlight?: number + ): string { + let maxLine = 0; + changes.forEach((change: ViewUnifiedChange) => { + if (change.getLineTo() > maxLine) { + maxLine = change.getLineTo(); + } + }, 0); + + const numberedHtml = this.lineNumbering.insertLineNumbers(motion.text, motion.lineLength); + let data; + + try { + data = this.diff.extractRangeByLineNumbers(numberedHtml, maxLine, null); + } catch (e) { + // This only happens (as far as we know) when the motion text has been altered (shortened) + // without modifying the change recommendations accordingly. + // That's a pretty serious inconsistency that should not happen at all, + // we're just doing some basic damage control here. + const msg = + 'Inconsistent data. A change recommendation is probably referring to a non-existant line number.'; + return '' + msg + ''; + } + + 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 '' + msg + ''; + } + + 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; + } }