diff --git a/client/src/app/shared/models/motions/workflow-state.ts b/client/src/app/shared/models/motions/workflow-state.ts index 75e9c5d13..ac649ac7c 100644 --- a/client/src/app/shared/models/motions/workflow-state.ts +++ b/client/src/app/shared/models/motions/workflow-state.ts @@ -1,6 +1,15 @@ import { Deserializer } from '../base/deserializer'; import { Workflow } from './workflow'; +/** + * Specifies if an amendment of this state/recommendation should be merged into the motion + */ +export enum MergeAmendment { + NO = -1, + UNDEFINED = 0, + YES = 1 +} + /** * Representation of a workflow state * @@ -18,7 +27,8 @@ export class WorkflowState extends Deserializer { public allow_create_poll: boolean; public allow_submitter_edit: boolean; public dont_set_identifier: boolean; - public show_state_extension_field: boolean; + public show_state_extension_field: number; + public merge_amendment_into_final: MergeAmendment; public show_recommendation_extension_field: boolean; public next_states_id: number[]; public workflow_id: number; diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts index 68d03aafb..1100680e4 100644 --- a/client/src/app/shared/shared.module.ts +++ b/client/src/app/shared/shared.module.ts @@ -23,7 +23,8 @@ import { DateAdapter, MatIconModule, MatButtonToggleModule, - MatBadgeModule + MatBadgeModule, + MatStepperModule } from '@angular/material'; import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatChipsModule } from '@angular/material'; @@ -110,6 +111,7 @@ import { SortingTreeComponent } from './components/sorting-tree/sorting-tree.com MatIconModule, MatRadioModule, MatButtonToggleModule, + MatStepperModule, DragDropModule, TranslateModule.forChild(), RouterModule, @@ -147,6 +149,7 @@ import { SortingTreeComponent } from './components/sorting-tree/sorting-tree.com MatIconModule, MatRadioModule, MatButtonToggleModule, + MatStepperModule, DragDropModule, NgxMatSelectSearchModule, FileDropModule, diff --git a/client/src/app/site/motions/components/amendment-create-wizard/amendment-create-wizard.component.html b/client/src/app/site/motions/components/amendment-create-wizard/amendment-create-wizard.component.html new file mode 100644 index 000000000..f35d4faaa --- /dev/null +++ b/client/src/app/site/motions/components/amendment-create-wizard/amendment-create-wizard.component.html @@ -0,0 +1,52 @@ + + +

Create amendment

+
+ +
+ + + Select paragraph +
+
+ +
+
+
+
+ +
+
+ + Specify changes + +
Amended paragraph
+ + + +
Reason
+ + + +
+ + +
+
+
+
diff --git a/client/src/app/site/motions/components/amendment-create-wizard/amendment-create-wizard.component.scss b/client/src/app/site/motions/components/amendment-create-wizard/amendment-create-wizard.component.scss new file mode 100644 index 000000000..22d512dec --- /dev/null +++ b/client/src/app/site/motions/components/amendment-create-wizard/amendment-create-wizard.component.scss @@ -0,0 +1,85 @@ +.paragraph-row { + display: flex; + flex-direction: row; + cursor: pointer; + padding: 20px 0; + + &:hover { + background-color: #eee; + } + &.active { + cursor: default; + background-color: #ccc; + &:hover { + background-color: #ccc; + } + } + + .paragraph-select { + flex-basis: 50px; + flex-grow: 0; + + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + } + .paragraph-text { + flex: 1; + } +} + +:host ::ng-deep .motion-text { + p, + ul, + ol, + li, + blockquote { + margin-top: 0; + margin-bottom: 0; + } + + li { + padding-bottom: 10px; + } + + ol, + ul { + margin-left: 15px; + margin-bottom: 0; + } + + padding-left: 40px; + position: relative; + + .os-line-number { + display: inline-block; + font-size: 0; + line-height: 0; + width: 22px; + height: 22px; + position: absolute; + left: 0; + padding-right: 55px; + + &:after { + content: attr(data-line-number); + position: absolute; + top: 10px; + vertical-align: top; + color: gray; + font-size: 12px; + font-weight: normal; + } + } +} + +.wide-form { + textarea { + height: 25vh; + } + + ::ng-deep { + width: 100%; + } +} diff --git a/client/src/app/site/motions/components/amendment-create-wizard/amendment-create-wizard.component.spec.ts b/client/src/app/site/motions/components/amendment-create-wizard/amendment-create-wizard.component.spec.ts new file mode 100644 index 000000000..ada8008cd --- /dev/null +++ b/client/src/app/site/motions/components/amendment-create-wizard/amendment-create-wizard.component.spec.ts @@ -0,0 +1,26 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AmendmentCreateWizardComponent } from './amendment-create-wizard.component'; +import { E2EImportsModule } from '../../../../../e2e-imports.module'; + +describe('AmendmentCreateWizardComponent', () => { + let component: AmendmentCreateWizardComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [AmendmentCreateWizardComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AmendmentCreateWizardComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/motions/components/amendment-create-wizard/amendment-create-wizard.component.ts b/client/src/app/site/motions/components/amendment-create-wizard/amendment-create-wizard.component.ts new file mode 100644 index 000000000..9161f8713 --- /dev/null +++ b/client/src/app/site/motions/components/amendment-create-wizard/amendment-create-wizard.component.ts @@ -0,0 +1,176 @@ +import { Component } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { DomSanitizer, SafeHtml, Title } from '@angular/platform-browser'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { MatSnackBar } from '@angular/material'; + +import { TranslateService } from '@ngx-translate/core'; + +import { MotionRepositoryService } from '../../services/motion-repository.service'; +import { ViewMotion } from '../../models/view-motion'; +import { LinenumberingService } from '../../services/linenumbering.service'; +import { Motion } from '../../../../shared/models/motions/motion'; +import { BaseViewComponent } from '../../../base/base-view'; + +/** + * Describes the single paragraphs from the base motion. + */ +interface ParagraphToChoose { + /** + * The paragraph number. + */ + paragraphNo: number; + + /** + * The raw HTML of this paragraph. + */ + rawHtml: string; + + /** + * The HTML of this paragraph, wrapped in a `SafeHtml`-object. + */ + safeHtml: SafeHtml; +} + +/** + * The wizard used to create a new amendment based on a motion. + */ +@Component({ + selector: 'os-amendment-create-wizard', + templateUrl: './amendment-create-wizard.component.html', + styleUrls: ['./amendment-create-wizard.component.scss'] +}) +export class AmendmentCreateWizardComponent extends BaseViewComponent { + /** + * The motion to be amended + */ + public motion: ViewMotion; + + /** + * The paragraphs of the base motion + */ + public paragraphs: ParagraphToChoose[]; + + /** + * Change recommendation content. + */ + public contentForm: FormGroup; + + /** + * Motions meta-info + */ + public metaInfoForm: FormGroup; + + /** + * Constructs this component. + * + * @param {Title} titleService set the browser title + * @param {TranslateService} translate the translation service + * @param {FormBuilder} formBuilder Form builder + * @param {MotionRepositoryService} repo Motion Repository + * @param {ActivatedRoute} route The activated route + * @param {Router} router The router + * @param {DomSanitizer} sanitizer The DOM Sanitizing library + * @param {LinenumberingService} lineNumbering The line numbering service + * @param {MatSnackBar} matSnackBar Material Design SnackBar + */ + public constructor( + titleService: Title, + translate: TranslateService, + private formBuilder: FormBuilder, + private repo: MotionRepositoryService, + private route: ActivatedRoute, + private router: Router, + private sanitizer: DomSanitizer, + private lineNumbering: LinenumberingService, + matSnackBar: MatSnackBar + ) { + super(titleService, translate, matSnackBar); + this.getMotionByUrl(); + this.createForm(); + } + + /** + * determine the motion to display using the URL + */ + public getMotionByUrl(): void { + // load existing motion + this.route.params.subscribe(params => { + this.repo.getViewModelObservable(params.id).subscribe(newViewMotion => { + this.motion = newViewMotion; + + this.paragraphs = this.repo + .getTextParagraphs(this.motion, true) + .map((paragraph: string, index: number) => { + return { + paragraphNo: index, + safeHtml: this.sanitizer.bypassSecurityTrustHtml(paragraph), + rawHtml: this.lineNumbering.stripLineNumbers(paragraph) + }; + }); + }); + }); + } + + /** + * Creates the forms for the Motion and the MotionVersion + */ + public createForm(): void { + this.contentForm = this.formBuilder.group({ + selectedParagraph: [null, Validators.required], + text: ['', Validators.required], + reason: ['', Validators.required] + }); + this.metaInfoForm = this.formBuilder.group({ + identifier: [''], + category_id: [''], + state_id: [''], + recommendation_id: [''], + submitters_id: [], + supporters_id: [], + origin: [''] + }); + } + + /** + * Called by the template when a paragraph is clicked. + * + * @param {ParagraphToChoose} paragraph + */ + public selectParagraph(paragraph: ParagraphToChoose): void { + this.contentForm.patchValue({ + selectedParagraph: paragraph.paragraphNo, + text: paragraph.rawHtml + }); + } + + /** + * Saves the amendment and navigates to detail view of this amendment + * + * @returns {Promise} + */ + public async saveAmendment(): Promise { + const amendedParagraphs = this.paragraphs.map( + (paragraph: ParagraphToChoose, index: number): string => { + if (index === this.contentForm.value.selectedParagraph) { + return this.contentForm.value.text; + } else { + return null; + } + } + ); + const newMotionValues = { + ...this.metaInfoForm.value, + ...this.contentForm.value, + title: this.translate.instant('Amendment to') + ' ' + this.motion.identifier, + parent_id: this.motion.id, + amendment_paragraphs: amendedParagraphs + }; + + const fromForm = new Motion(); + fromForm.deserialize(newMotionValues); + + const response = await this.repo.create(fromForm); + this.router.navigate(['./motions/' + response.id]); + } +} 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 index 9638638c8..16a8245c2 100644 --- 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 @@ -1,73 +1,78 @@
- - {{ 'Summary of changes' | translate }}: - + {{ 'Summary of changes' | translate }}: -
- {{ 'No change recommendations yet' | translate }} -
+
{{ 'No change recommendations yet' | translate }}
-
-
-
+
+
warning
- @@ -78,41 +83,53 @@ {{ change.original.identifier }} - --> + -->
- {{ 'Rejected' | translate }}: + {{ 'Rejected' | translate }}
-
+
- - + + + + -
-

{{ motion.title }}

-

{{ contentForm.get("title").value }}

+

{{ contentForm.get('title').value }}

- + @@ -94,7 +113,7 @@ - + format_align_left @@ -115,7 +134,6 @@
-
@@ -123,30 +141,30 @@ -
- - - - +
-
- +
-
+

Identifier

{{ motion.identifier }}
- +
@@ -154,8 +172,14 @@
- +
@@ -170,8 +194,14 @@
- +
@@ -259,54 +289,85 @@
- +
-
-
+
+

Origin

{{ motion.origin }}
- +
- +
+ --> -
- - + +
- -
- + Statute amendment - + {{ paragraph.title }} @@ -315,43 +376,75 @@
-
- - +
+
+

{{ motion.title }}

+
+ + +
{{ preamble | translate }} - -
- -
+ + + +
+ +
- +
-
-
+
- + + + + + +
Reason
-
-
-
+
- + +
+
+ No changes at the text. +
+
+
+
+
+ +

+ Line {{ paragraph.diffLineFrom }}: +

+

+ Line {{ paragraph.diffLineFrom }} - {{ paragraph.diffLineTo - 1 }}: +

+ +
+
+
+ +
+
+
+
+
+
+
@@ -375,8 +508,8 @@ - - - - + + + + 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 e5a7e6ce3..1f6a9f6cf 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 @@ -268,4 +268,33 @@ span { display: none; } } + + .os-split-before { + margin-top: 0; + padding-top: 0; + } + + .os-split-after { + margin-bottom: 0; + padding-bottom: 0; + } + + li.os-split-before { + list-style: none; + } +} + +:host ::ng-deep .amendment-view { + .os-split-after { + margin-bottom: 0; + } + .os-split-before { + margin-top: 0; + } + .paragraph-context { + opacity: 0.5; + } + &.amendment-context .paragraph-context { + opacity: 1; + } } 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 3b27e23f3..bc01dd23a 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 @@ -12,7 +12,7 @@ import { DataStoreService } from '../../../../core/services/data-store.service'; import { TranslateService } from '@ngx-translate/core'; import { Motion } from '../../../../shared/models/motions/motion'; import { BehaviorSubject, Subscription, ReplaySubject, concat } from 'rxjs'; -import { LineRange } from '../../services/diff.service'; +import { DiffLinesInParagraph, LineRange } from '../../services/diff.service'; import { MotionChangeRecommendationComponent, MotionChangeRecommendationComponentData @@ -110,6 +110,11 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit { */ public preamble: string; + /** + * Value of the configuration variable `motions_amendments_enabled` - are amendments enabled? + * @TODO replace by direct access to config variable, once it's available from the templates + */ + public amendmentsEnabled: boolean; /** * Copy of the motion that the user might edit @@ -121,6 +126,11 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit { */ public changeRecommendations: ViewChangeReco[]; + /** + * All amendments to this motions + */ + public amendments: ViewMotion[]; + /** * All change recommendations AND amendments, sorted by line number. */ @@ -186,6 +196,11 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit { */ private recommenderSubscription: Subscription; + /** + * If this is a paragraph-based amendment, this indicates if the non-affected paragraphs should be shown as well + */ + public showAmendmentContext = false; + /** * Constuct the detail view. * @@ -255,11 +270,18 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit { this.minSupporters = supporters; } ); + this.configService.get('motions_preamble').subscribe( (preamble: string): void => { this.preamble = preamble; } ); + + this.configService.get('motions_amendments_enabled').subscribe( + (enabled: boolean): void => { + this.amendmentsEnabled = enabled; + } + ); } /** @@ -267,8 +289,25 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit { * Called each time one of these arrays changes. */ private recalcUnifiedChanges(): void { - // @TODO implement amendments - this.allChangingObjects = this.changeRecommendations; + this.allChangingObjects = []; + if (this.changeRecommendations) { + this.changeRecommendations.forEach( + (change: ViewUnifiedChange): void => { + this.allChangingObjects.push(change); + } + ); + } + if (this.amendments) { + this.amendments.forEach( + (amendment: ViewMotion): void => { + this.repo.getAmendmentAmendedParagraphs(amendment).forEach( + (change: ViewUnifiedChange): void => { + this.allChangingObjects.push(change); + } + ); + } + ); + } this.allChangingObjects.sort((a: ViewUnifiedChange, b: ViewUnifiedChange) => { if (a.getLineFrom() < b.getLineFrom()) { return -1; @@ -293,18 +332,23 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit { } else { // load existing motion this.route.params.subscribe(params => { - this.repo.getViewModelObservable(params.id).subscribe(newViewMotion => { + const motionId: number = parseInt(params.id, 10); + this.repo.getViewModelObservable(motionId).subscribe(newViewMotion => { if (newViewMotion) { this.motion = newViewMotion; this.patchForm(this.motion); } }); - this.changeRecoRepo - .getChangeRecosOfMotionObservable(parseInt(params.id, 10)) - .subscribe((recos: ViewChangeReco[]) => { - this.changeRecommendations = recos; + this.repo.amendmentsTo(motionId).subscribe( + (amendments: ViewMotion[]): void => { + this.amendments = amendments; this.recalcUnifiedChanges(); - }); + } + ); + this.changeRecoRepo.getChangeRecosOfMotionObservable(motionId).subscribe((recos: ViewChangeReco[]) => { + this.changeRecommendations = recos; + this.recalcUnifiedChanges(); + }); }); } } @@ -323,6 +367,15 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit { Object.keys(this.contentForm.controls).forEach(ctrl => { contentPatch[ctrl] = formMotion[ctrl]; }); + + if (formMotion.isParagraphBasedAmendment()) { + contentPatch.text = formMotion.amendment_paragraphs.find( + (para: string): boolean => { + return para !== null; + } + ); + } + const statuteAmendmentFieldName = 'statute_amendment'; contentPatch[statuteAmendmentFieldName] = formMotion.isStatuteAmendment(); this.contentForm.patchValue(contentPatch); @@ -377,6 +430,18 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit { const newMotionValues = { ...this.metaInfoForm.value, ...this.contentForm.value }; const fromForm = new Motion(); + if (this.motion.isParagraphBasedAmendment()) { + fromForm.amendment_paragraphs = this.motion.amendment_paragraphs.map( + (para: string): string => { + if (para === null) { + return null; + } else { + return newMotionValues.text; + } + } + ); + newMotionValues.text = ''; + } fromForm.deserialize(newMotionValues); try { @@ -409,11 +474,36 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit { } /** - * get the formatted motion text from the repository, as SafeHTML for [innerHTML] + * Called from the template to make a HTML string compatible with [innerHTML] + * (otherwise line-number-data-attributes would be stripped out) + * + * @param {string} text * @returns {SafeHtml} */ - public getFormattedText(): SafeHtml { - return this.sanitizer.bypassSecurityTrustHtml(this.getFormattedTextPlain()); + public sanitizedText(text: string): SafeHtml { + return this.sanitizer.bypassSecurityTrustHtml(text); + } + + /** + * If `this.motion` is an amendment, this returns the list of all changed paragraphs. + * + * @returns {DiffLinesInParagraph[]} + */ + public getAmendedParagraphs(): DiffLinesInParagraph[] { + return this.repo.getAmendedParagraphs(this.motion); + } + + /** + * If `this.motion` is an amendment, this returns a specified line range from the parent motion + * (e.g. to show the contect in which this amendment is happening) + * + * @param {number} from + * @param {number} to + * @returns {SafeHtml} + */ + public getParentMotionRange(from: number, to: number): SafeHtml { + const str = this.repo.extractMotionLineRange(this.motion.parent_id, { from, to }, true); + return this.sanitizer.bypassSecurityTrustHtml(str); } /** @@ -515,6 +605,13 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit { this.setChangeRecoMode(ChangeRecoMode.Diff); } + /** + * Goes to the amendment creation wizard. Executed via click. + */ + public createAmendment(): void { + this.router.navigate(['./create-amendment'], { relativeTo: this.route }); + } + /** * Comes from the head bar * @param mode @@ -539,14 +636,24 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit { const configKey = isStatuteAmendment ? 'motions_statute_amendments_workflow' : 'motions_workflow'; // TODO: This should just be a takeWhile(id => !id), but should include the last one where the id is OK. // takeWhile will get a inclusive parameter, see https://github.com/ReactiveX/rxjs/pull/4115 - this.configService.get(configKey).pipe(multicast( - () => new ReplaySubject(1), - (ids) => ids.pipe(takeWhile(id => !id), o => concat(o, ids.pipe(take(1)))) - ), skipWhile(id => !id)).subscribe(id => { - this.metaInfoForm.patchValue({ - workflow_id: parseInt(id, 10), + this.configService + .get(configKey) + .pipe( + multicast( + () => new ReplaySubject(1), + ids => + ids.pipe( + takeWhile(id => !id), + o => concat(o, ids.pipe(take(1))) + ) + ), + skipWhile(id => !id) + ) + .subscribe(id => { + this.metaInfoForm.patchValue({ + workflow_id: parseInt(id, 10) + }); }); - }); } /** @@ -655,7 +762,9 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit { * Observes the repository for changes in the motion recommender */ public setupRecommender(): void { - const configKey = this.motion.isStatuteAmendment() ? 'motions_statute_recommendations_by' : 'motions_recommendations_by'; + const configKey = this.motion.isStatuteAmendment() + ? 'motions_statute_recommendations_by' + : 'motions_recommendations_by'; if (this.recommenderSubscription) { this.recommenderSubscription.unsubscribe(); } diff --git a/client/src/app/site/motions/models/view-motion-amended-paragraph.ts b/client/src/app/site/motions/models/view-motion-amended-paragraph.ts new file mode 100644 index 000000000..58426edb7 --- /dev/null +++ b/client/src/app/site/motions/models/view-motion-amended-paragraph.ts @@ -0,0 +1,80 @@ +import { ViewUnifiedChange, ViewUnifiedChangeType } from './view-unified-change'; +import { ViewMotion } from './view-motion'; +import { LineRange } from '../services/diff.service'; +import { MergeAmendment } from '../../../shared/models/motions/workflow-state'; + +/** + * This represents the Unified Diff part of an amendments. + * + * Hint: As we will probably support multiple affected paragraphs in one amendment in the future, + * Amendments <-> ViewMotionAmendedParagraph is potentially a 1:n-relation + */ +export class ViewMotionAmendedParagraph implements ViewUnifiedChange { + public constructor( + private amendment: ViewMotion, + private paragraphNo: number, + private newText: string, + private lineRange: LineRange + ) {} + + public getChangeId(): string { + return 'amendment-' + this.amendment.id.toString(10) + '-' + this.paragraphNo.toString(10); + } + + public getChangeType(): ViewUnifiedChangeType { + return ViewUnifiedChangeType.TYPE_AMENDMENT; + } + + public getLineFrom(): number { + return this.lineRange.from; + } + + public getLineTo(): number { + return this.lineRange.to; + } + + public getChangeNewText(): string { + return this.newText; + } + + /** + * The state and recommendation of this amendment is considered. + * The state takes precedence. + * + * @returns {boolean} + */ + public isAccepted(): boolean { + const mergeState = this.amendment.state + ? this.amendment.state.merge_amendment_into_final + : MergeAmendment.UNDEFINED; + switch (mergeState) { + case MergeAmendment.YES: + return true; + case MergeAmendment.NO: + return false; + default: + const mergeRecommendation = this.amendment.recommendation + ? this.amendment.recommendation.merge_amendment_into_final + : MergeAmendment.UNDEFINED; + switch (mergeRecommendation) { + case MergeAmendment.YES: + return true; + case MergeAmendment.NO: + return false; + default: + return false; + } + } + } + + /** + * @returns {boolean} + */ + public isRejected(): boolean { + return !this.isAccepted(); + } + + public getIdentifier(): string { + return this.amendment.identifier; + } +} diff --git a/client/src/app/site/motions/models/view-motion.ts b/client/src/app/site/motions/models/view-motion.ts index 8cbcfd5bc..91d93bbc6 100644 --- a/client/src/app/site/motions/models/view-motion.ts +++ b/client/src/app/site/motions/models/view-motion.ts @@ -205,6 +205,14 @@ export class ViewMotion extends BaseViewModel { return this.item ? this.item.speakerAmount : null; } + public get parent_id(): number { + return this.motion && this.motion.parent_id ? this.motion.parent_id : null; + } + + public get amendment_paragraphs(): string[] { + return this.motion && this.motion.amendment_paragraphs ? this.motion.amendment_paragraphs : []; + } + public constructor( motion?: Motion, category?: Category, @@ -340,6 +348,14 @@ export class ViewMotion extends BaseViewModel { return !!this.statute_paragraph_id; } + /** + * It's a paragraph-based amendments if only one paragraph is to be changed, + * specified by amendment_paragraphs-array + */ + public isParagraphBasedAmendment(): boolean { + return this.amendment_paragraphs.length > 0; + } + /** * Duplicate this motion into a copy of itself */ diff --git a/client/src/app/site/motions/motions-routing.module.ts b/client/src/app/site/motions/motions-routing.module.ts index f5c0b6e73..8273351e1 100644 --- a/client/src/app/site/motions/motions-routing.module.ts +++ b/client/src/app/site/motions/motions-routing.module.ts @@ -7,6 +7,7 @@ import { MotionCommentSectionListComponent } from './components/motion-comment-s import { StatuteParagraphListComponent } from './components/statute-paragraph-list/statute-paragraph-list.component'; import { SpeakerListComponent } from '../agenda/components/speaker-list/speaker-list.component'; import { CallListComponent } from './components/call-list/call-list.component'; +import { AmendmentCreateWizardComponent } from './components/amendment-create-wizard/amendment-create-wizard.component'; const routes: Routes = [ { path: '', component: MotionListComponent }, @@ -16,7 +17,8 @@ const routes: Routes = [ { path: 'call-list', component: CallListComponent }, { path: 'new', component: MotionDetailComponent }, { path: ':id', component: MotionDetailComponent }, - { path: ':id/speakers', component: SpeakerListComponent } + { path: ':id/speakers', component: SpeakerListComponent }, + { path: ':id/create-amendment', component: AmendmentCreateWizardComponent } ]; @NgModule({ diff --git a/client/src/app/site/motions/motions.module.ts b/client/src/app/site/motions/motions.module.ts index 99d2db40d..15d0e77d9 100644 --- a/client/src/app/site/motions/motions.module.ts +++ b/client/src/app/site/motions/motions.module.ts @@ -15,6 +15,7 @@ import { MotionCommentsComponent } from './components/motion-comments/motion-com import { MetaTextBlockComponent } from './components/meta-text-block/meta-text-block.component'; import { PersonalNoteComponent } from './components/personal-note/personal-note.component'; import { CallListComponent } from './components/call-list/call-list.component'; +import { AmendmentCreateWizardComponent } from './components/amendment-create-wizard/amendment-create-wizard.component'; @NgModule({ imports: [CommonModule, MotionsRoutingModule, SharedModule], @@ -30,7 +31,8 @@ import { CallListComponent } from './components/call-list/call-list.component'; MotionCommentsComponent, MetaTextBlockComponent, PersonalNoteComponent, - CallListComponent + CallListComponent, + AmendmentCreateWizardComponent ], entryComponents: [ MotionChangeRecommendationComponent, diff --git a/client/src/app/site/motions/services/diff.service.ts b/client/src/app/site/motions/services/diff.service.ts index 2b5d8711c..945dd571d 100644 --- a/client/src/app/site/motions/services/diff.service.ts +++ b/client/src/app/site/motions/services/diff.service.ts @@ -114,6 +114,44 @@ export interface LineRange { to: number; } +/** + * An object representing a paragraph with some changed lines + */ +export interface DiffLinesInParagraph { + /** + * The paragraph number + */ + paragraphNo: number; + /** + * The first line of the paragraph + */ + paragraphLineFrom: number; + /** + * The end line number (after the paragraph) + */ + paragraphLineTo: number; + /** + * The first line number with changes + */ + diffLineFrom: number; + /** + * The line number after the last change + */ + diffLineTo: number; + /** + * The HTML of the not-changed lines before the changed ones + */ + textPre: string; + /** + * The HTML of the changed lines + */ + text: string; + /** + * The HTML of the not-changed lines after the changed ones + */ + textPost: string; +} + /** * Functionality regarding diffing, merging and extracting line ranges. * @@ -1024,10 +1062,11 @@ export class DiffService { return tagStr.replace( /<(\w+)( [^>]*)?>/gi, (whole: string, tag: string, tagArguments: string): string => { - tagArguments = (tagArguments ? tagArguments : ''); + tagArguments = tagArguments ? tagArguments : ''; if (tagArguments.match(/class="/gi)) { // class="someclass" => class="someclass insert" - tagArguments = tagArguments.replace(/(class\s*=\s*)(["'])([^\2]*)\2/gi, + tagArguments = tagArguments.replace( + /(class\s*=\s*)(["'])([^\2]*)\2/gi, (classWhole: string, attr: string, para: string, content: string): string => { return attr + para + content + ' ' + className + para; } @@ -1038,7 +1077,7 @@ export class DiffService { return '<' + tag + tagArguments + '>'; } ); - }; + } /** * This fixes a very specific, really weird bug that is tested in the test case "does not a change in a very specific case". @@ -1450,6 +1489,18 @@ export class DiffService { return ret; } + /** + * Convenience method that takes the html-attribute from an extractRangeByLineNumbers()-method and + * wraps it with the context. + * + * @param {ExtractedContent} diff + */ + public formatDiff(diff: ExtractedContent): string { + return ( + diff.outerContextStart + diff.innerContextStart + diff.html + diff.innerContextEnd + diff.outerContextEnd + ); + } + /** * Convenience method that takes the html-attribute from an extractRangeByLineNumbers()-method, * wraps it with the context and adds line numbers. @@ -1459,8 +1510,7 @@ export class DiffService { * @param {number} firstLine */ public formatDiffWithLineNumbers(diff: ExtractedContent, lineLength: number, firstLine: number): string { - let text = - diff.outerContextStart + diff.innerContextStart + diff.html + diff.innerContextEnd + diff.outerContextEnd; + let text = this.formatDiff(diff); text = this.lineNumberingService.insertLineNumbers(text, lineLength, null, null, firstLine); return text; } @@ -1921,7 +1971,7 @@ export class DiffService { diffUnnormalized = diffUnnormalized.replace( /<(ins|del)>([\s\S]*?)<\/\1>/gi, (whole: string, insDel: string): string => { - const modificationClass = (insDel.toLowerCase() === 'ins' ? 'insert' : 'delete'); + const modificationClass = insDel.toLowerCase() === 'ins' ? 'insert' : 'delete'; return whole.replace( /(<(p|div|blockquote|li)[^>]*>)([\s\S]*?)(<\/\2>)/gi, (whole2: string, opening: string, blockTag: string, content: string, closing: string): string => { @@ -2017,4 +2067,62 @@ export class DiffService { return html; } + + /** + * This is used to extract affected lines of a paragraph with the possibility to show the context (lines before + * and after) the changed lines and displaying the line numbers. + * + * @param {number} paragraphNo The paragraph number + * @param {string} origText The original text - needs to be line-numbered + * @param {string} newText The changed text + * @param {number} lineLength the line length + * @return {DiffLinesInParagraph|null} + */ + public getAmendmentParagraphsLinesByMode( + paragraphNo: number, + origText: string, + newText: string, + lineLength: number + ): DiffLinesInParagraph { + const paragraph_line_range = this.lineNumberingService.getLineNumberRange(origText), + diff = this.diff(origText, newText), + affected_lines = this.detectAffectedLineRange(diff); + + if (affected_lines === null) { + return null; + } + + let textPre = ''; + let textPost = ''; + if (affected_lines.from > paragraph_line_range.from) { + textPre = this.formatDiffWithLineNumbers( + this.extractRangeByLineNumbers(diff, paragraph_line_range.from, affected_lines.from), + lineLength, + paragraph_line_range.from + ); + } + if (paragraph_line_range.to > affected_lines.to) { + textPost = this.formatDiffWithLineNumbers( + this.extractRangeByLineNumbers(diff, affected_lines.to, paragraph_line_range.to), + lineLength, + affected_lines.to + ); + } + const text = this.formatDiffWithLineNumbers( + this.extractRangeByLineNumbers(diff, affected_lines.from, affected_lines.to), + lineLength, + affected_lines.from + ); + + return { + paragraphNo: paragraphNo, + paragraphLineFrom: paragraph_line_range.from, + paragraphLineTo: paragraph_line_range.to, + diffLineFrom: affected_lines.from, + diffLineTo: affected_lines.to, + textPre: textPre, + text: text, + textPost: textPost + } as DiffLinesInParagraph; + } } 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 ea29548b2..15bd26d1a 100644 --- a/client/src/app/site/motions/services/motion-repository.service.ts +++ b/client/src/app/site/motions/services/motion-repository.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; -import { tap } from 'rxjs/operators'; +import { tap, map } from 'rxjs/operators'; import { DataSendService } from '../../../core/services/data-send.service'; import { Motion } from '../../../shared/models/motions/motion'; @@ -13,7 +13,7 @@ import { ChangeRecoMode, ViewMotion } from '../models/view-motion'; import { BaseRepository } from '../../base/base-repository'; import { DataStoreService } from '../../../core/services/data-store.service'; import { LinenumberingService } from './linenumbering.service'; -import { DiffService, LineRange, ModificationType } from './diff.service'; +import { DiffLinesInParagraph, DiffService, LineRange, ModificationType } from './diff.service'; import { ViewChangeReco } from '../models/view-change-reco'; import { MotionChangeReco } from '../../../shared/models/motions/motion-change-reco'; import { ViewUnifiedChange } from '../models/view-unified-change'; @@ -24,6 +24,7 @@ import { HttpService } from 'app/core/services/http.service'; import { Item } from 'app/shared/models/agenda/item'; import { OSTreeSortEvent } from 'app/shared/components/sorting-tree/sorting-tree.component'; import { TreeService } from 'app/core/services/tree.service'; +import { ViewMotionAmendedParagraph } from '../models/view-motion-amended-paragraph'; /** * Repository Services for motions (and potentially categories) @@ -207,6 +208,25 @@ export class MotionRepositoryService extends BaseRepository await this.httpService.delete(url); } + /** Returns an observable returning the amendments to a given motion + * + * @param {number} motionId + * @returns {Observable} + */ + public amendmentsTo(motionId: number): Observable { + return this.getViewModelListObservable().pipe( + map( + (motions: ViewMotion[]): ViewMotion[] => { + return motions.filter( + (motion: ViewMotion): boolean => { + return motion.parent_id === motionId; + } + ); + } + ) + ); + } + /** * Format the motion text using the line numbering and change * reco algorithm. @@ -311,6 +331,7 @@ export class MotionRepositoryService extends BaseRepository * @param {ViewMotion} motion * @param {ViewUnifiedChange[]} changes * @param {number} highlight + * @returns {string} */ public getTextRemainderAfterLastChange( motion: ViewMotion, @@ -381,6 +402,7 @@ export class MotionRepositoryService extends BaseRepository * @param {ViewMotion} motion * @param {ViewUnifiedChange} change * @param {number} highlight + * @returns {string} */ public getChangeDiff(motion: ViewMotion, change: ViewUnifiedChange, highlight?: number): string { const lineLength = motion.lineLength, @@ -427,4 +449,106 @@ export class MotionRepositoryService extends BaseRepository return diff; } + + /** + * Given an amendment, this returns the motion affected by this amendments + * + * @param {ViewMotion} amendment + * @returns {ViewMotion} + */ + public getAmendmentBaseMotion(amendment: ViewMotion): ViewMotion { + return this.getViewModel(amendment.parent_id); + } + + /** + * Splits a motion into paragraphs, optionally adding line numbers + * + * @param {ViewMotion} motion + * @param {boolean} lineBreaks + * @returns {string[]} + */ + public getTextParagraphs(motion: ViewMotion, lineBreaks: boolean): string[] { + if (!motion) { + return []; + } + let html = motion.text; + if (lineBreaks) { + const lineLength = motion.lineLength; + html = this.lineNumbering.insertLineNumbers(html, lineLength); + } + return this.lineNumbering.splitToParagraphs(html); + } + + /** + * Returns all paragraphs that are affected by the given amendment in diff-format + * + * @param {ViewMotion} amendment + * @returns {DiffLinesInParagraph} + */ + public getAmendedParagraphs(amendment: ViewMotion): DiffLinesInParagraph[] { + const motion = this.getAmendmentBaseMotion(amendment); + const baseParagraphs = this.getTextParagraphs(motion, true); + const lineLength = amendment.lineLength; + + return amendment.amendment_paragraphs + .map( + (newText: string, paraNo: number): DiffLinesInParagraph => { + if (newText === null) { + return null; + } + // Hint: can be either DiffLinesInParagraph or null, if no changes are made + return this.diff.getAmendmentParagraphsLinesByMode( + paraNo, + baseParagraphs[paraNo], + newText, + lineLength + ); + } + ) + .filter((para: DiffLinesInParagraph) => para !== null); + } + + /** + * Returns all paragraphs that are affected by the given amendment as unified change objects. + * + * @param {ViewMotion} amendment + * @returns {ViewMotionAmendedParagraph[]} + */ + public getAmendmentAmendedParagraphs(amendment: ViewMotion): ViewMotionAmendedParagraph[] { + const motion = this.getAmendmentBaseMotion(amendment); + const baseParagraphs = this.getTextParagraphs(motion, true); + const lineLength = amendment.lineLength; + + return amendment.amendment_paragraphs + .map( + (newText: string, paraNo: number): ViewMotionAmendedParagraph => { + if (newText === null) { + return null; + } + + const origText = baseParagraphs[paraNo], + paragraphLines = this.lineNumbering.getLineNumberRange(origText), + diff = this.diff.diff(origText, newText), + affectedLines = this.diff.detectAffectedLineRange(diff); + + if (affectedLines === null) { + return null; + } + + let newTextLines = this.lineNumbering.insertLineNumbers( + newText, + lineLength, + null, + null, + paragraphLines.from + ); + newTextLines = this.diff.formatDiff( + this.diff.extractRangeByLineNumbers(newTextLines, affectedLines.from, affectedLines.to) + ); + + return new ViewMotionAmendedParagraph(amendment, paraNo, newTextLines, affectedLines); + } + ) + .filter((para: ViewMotionAmendedParagraph) => para !== null); + } } diff --git a/openslides/motions/migrations/0016_merge_amendment_into_final.py b/openslides/motions/migrations/0016_merge_amendment_into_final.py new file mode 100644 index 000000000..670a8bced --- /dev/null +++ b/openslides/motions/migrations/0016_merge_amendment_into_final.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.2 on 2018-10-29 13:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('motions', '0015_metadata_permission'), + ] + + operations = [ + migrations.AddField( + model_name='state', + name='merge_amendment_into_final', + field=models.SmallIntegerField(default=0), + ), + ] diff --git a/openslides/motions/models.py b/openslides/motions/models.py index d4115c31e..7690f39a0 100644 --- a/openslides/motions/models.py +++ b/openslides/motions/models.py @@ -1099,6 +1099,20 @@ class State(RESTModelMixin, models.Model): state name and the entered value of this input field. """ + merge_amendment_into_final = models.SmallIntegerField(default=0) + """ + Relevant for amendments: + 1: Amendments of this statue or recommendation will be merged into the + final version of the motion. + 0: Undefined. + -1: Amendments of this status or recommendation will not be merged into the + final version of the motion. + + (Hint: The status field takes precedence. That means, if status is 1 or -1, + this is the final decision. The recommendation only is considered if the + status is 0) + """ + show_recommendation_extension_field = models.BooleanField(default=False) """ If true, an additional input field (from motion comment) is visible diff --git a/openslides/motions/serializers.py b/openslides/motions/serializers.py index 3d1d8f50b..c4f303ed9 100644 --- a/openslides/motions/serializers.py +++ b/openslides/motions/serializers.py @@ -103,6 +103,7 @@ class StateSerializer(ModelSerializer): 'allow_submitter_edit', 'dont_set_identifier', 'show_state_extension_field', + 'merge_amendment_into_final', 'show_recommendation_extension_field', 'next_states', 'workflow') diff --git a/openslides/motions/signals.py b/openslides/motions/signals.py index 3e7383612..1d6a9f85d 100644 --- a/openslides/motions/signals.py +++ b/openslides/motions/signals.py @@ -24,7 +24,8 @@ def create_builtin_workflows(sender, **kwargs): workflow=workflow_1, action_word='Accept', recommendation_label='Acceptance', - css_class='success') + css_class='success', + merge_amendment_into_final=True) state_1_3 = State.objects.create(name=ugettext_noop('rejected'), workflow=workflow_1, action_word='Reject', @@ -55,7 +56,8 @@ def create_builtin_workflows(sender, **kwargs): workflow=workflow_2, action_word='Accept', recommendation_label='Acceptance', - css_class='success') + css_class='success', + merge_amendment_into_final=True) state_2_4 = State.objects.create(name=ugettext_noop('rejected'), workflow=workflow_2, action_word='Reject',