diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-detail/motion-detail.component.ts b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-detail/motion-detail.component.ts index b12c22b455..a3bf540b37 100644 --- a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-detail/motion-detail.component.ts +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-detail/motion-detail.component.ts @@ -29,14 +29,18 @@ export class MotionDetailComponent extends BaseModelRequestHandlerComponent { } protected override onShouldCreateModelRequests(params: any, meetingId: Id): void { - if (params[`id`] && meetingId) { - this.loadMotionDetail(meetingId, +params[`id`]); + const id = params[`id`] || params[`parent`]; + console.log(id, params); + if (id && meetingId) { + this.loadMotionDetail(meetingId, +id); } } protected override onParamsChanged(params: any, oldParams: any): void { - if (params[`id`] !== oldParams[`id`] || params[`meetingId`] !== oldParams[`meetingId`]) { - this.loadMotionDetail(+params[`meetingId`], +params[`id`]); + const oldId = oldParams[`id`] || oldParams[`parent`]; + const newId = params[`id`] || params[`parent`]; + if (newId !== oldId || params[`meetingId`] !== oldParams[`meetingId`]) { + this.loadMotionDetail(+params[`meetingId`], +newId); } } diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/motion-form-content/motion-form-content.component.html b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/motion-form-content/motion-form-content.component.html deleted file mode 100644 index 68dc24a234..0000000000 --- a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/motion-form-content/motion-form-content.component.html +++ /dev/null @@ -1,207 +0,0 @@ -
- - @if (newMotion) { -
- @if (canChangeMetadata) { - - {{ 'Submitters' | translate }} - - - add - {{ 'Create user' | translate }} - - - - } -
- } - -
- - @if (editMotion && !newMotion && canChangeMetadata) { -
- @if (editMotion) { - - {{ 'Number' | translate }} - - {{ 'already exists' | translate }} - - } -
- } - - - @if (editMotion) { -
- @if (editMotion) { - - {{ 'Title' | translate }} - - {{ 'The title is required' | translate }} - - } -
- } -
- - - @if (editMotion && !isParagraphBasedAmendment) { - @if (preamble) { -

- {{ preamble }} -

- } - - @if (contentForm.get('text')?.invalid && (contentForm.get('text')?.dirty || contentForm.get('text')?.touched)) { -
- {{ 'This field is required.' | translate }} -
- } - } - - - @if (isParagraphBasedAmendment) { - - } - - - @if (motion?.reason || editMotion) { -
-

- {{ 'Reason' | translate }} -   - @if (reasonRequired && editMotion) { - * - } -

- @if (!editMotion) { - - } - - - @if (editMotion) { - - } - @if ( - reasonRequired && - contentForm.get('reason')?.invalid && - (contentForm.get('reason')?.dirty || contentForm.get('reason')?.touched) - ) { -
- {{ 'This field is required.' | translate }} -
- } -
- } - -
- - @if (newMotion && hasCategories) { -
- - {{ 'Category' | translate }} - - -
- } - - - @if (hasAttachments || editMotion) { -
- @if (!editMotion) { -
-

- {{ 'Attachments' | translate }} - attach_file -

- - @for (file of motion?.attachments; track file) { - - {{ file.title }} - - } - -
- } -
- -
-
- } - - @if (canChangeMetadata) { - @if (newMotion) { -
- -
- } - - - @if (editMotion && minSupporters) { -
- - {{ 'Supporters' | translate }} - - - add - {{ 'Create user' | translate }} - - - -
- } - - - @if (editMotion) { -
- - {{ 'Workflow' | translate }} - - -
- } - } -
-
diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/motion-form-content/motion-form-content.component.scss b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/motion-form-content/motion-form-content.component.scss deleted file mode 100644 index 76e869813b..0000000000 --- a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/motion-form-content/motion-form-content.component.scss +++ /dev/null @@ -1,5 +0,0 @@ -.extra-data { - display: flex; - flex-direction: column; - margin-top: 10px; -} diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/motion-form-content/motion-form-content.component.spec.ts b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/motion-form-content/motion-form-content.component.spec.ts deleted file mode 100644 index 215317b4a8..0000000000 --- a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/motion-form-content/motion-form-content.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { MotionFormContentComponent } from './motion-form-content.component'; - -xdescribe(`MotionContentComponent`, () => { - let component: MotionFormContentComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - declarations: [MotionFormContentComponent] - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(MotionFormContentComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it(`should create`, () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/motion-form-content/motion-form-content.component.ts b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/motion-form-content/motion-form-content.component.ts deleted file mode 100644 index cc8a6c8f63..0000000000 --- a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/motion-form-content/motion-form-content.component.ts +++ /dev/null @@ -1,435 +0,0 @@ -import { Component, EventEmitter, Output } from '@angular/core'; -import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; -import { ActivatedRoute } from '@angular/router'; -import { TranslateService } from '@ngx-translate/core'; -import { BehaviorSubject, distinctUntilChanged, map, Subscription } from 'rxjs'; -import { Id, UnsafeHtml } from 'src/app/domain/definitions/key-types'; -import { Mediafile } from 'src/app/domain/models/mediafiles/mediafile'; -import { Settings } from 'src/app/domain/models/meetings/meeting'; -import { Motion } from 'src/app/domain/models/motions/motion'; -import { ChangeRecoMode, LineNumberingMode } from 'src/app/domain/models/motions/motions.constants'; -import { RawUser } from 'src/app/gateways/repositories/users'; -import { deepCopy } from 'src/app/infrastructure/utils/transform-functions'; -import { isUniqueAmong } from 'src/app/infrastructure/utils/validators/is-unique-among'; -import { ViewMotion, ViewMotionCategory, ViewMotionWorkflow } from 'src/app/site/pages/meetings/pages/motions'; -import { LineRange } from 'src/app/site/pages/meetings/pages/motions/definitions'; -import { ViewUnifiedChange } from 'src/app/site/pages/meetings/pages/motions/modules/change-recommendations/view-models/view-unified-change'; - -import { ParticipantListSortService } from '../../../../../../../participants/pages/participant-list/services/participant-list-sort/participant-list-sort.service'; -import { getParticipantMinimalSubscriptionConfig } from '../../../../../../../participants/participants.subscription'; -import { MotionControllerService } from '../../../../../../services/common/motion-controller.service'; -import { MotionPermissionService } from '../../../../../../services/common/motion-permission.service/motion-permission.service'; -import { BaseMotionDetailChildComponent } from '../../../../base/base-motion-detail-child.component'; -import { MotionContentChangeRecommendationDialogComponentData } from '../../../../modules/motion-change-recommendation-dialog/components/motion-content-change-recommendation-dialog/motion-content-change-recommendation-dialog.component'; -import { MotionChangeRecommendationDialogService } from '../../../../modules/motion-change-recommendation-dialog/services/motion-change-recommendation-dialog.service'; - -/** - * fields that are required for the motion form but are not part of any motion payload - */ -interface MotionFormFields { - // from update payload - modified_final_version: string; - // apparently from no payload - parent_id: string; - - // For agenda creations - agenda_parent_id: Id; - - // Motion - workflow_id: Id; -} - -type MotionFormControlsConfig = { [key in keyof MotionFormFields]?: any } & { [key in keyof Motion]?: any } & { - supporter_ids?: any; -}; - -@Component({ - selector: `os-motion-form-content`, - templateUrl: `./motion-form-content.component.html`, - styleUrls: [`./motion-form-content.component.scss`] -}) -export class MotionFormContentComponent extends BaseMotionDetailChildComponent { - @Output() - public save = new EventEmitter(); - - @Output() - public formChanged = new EventEmitter(); - - @Output() - public validStateChanged = new EventEmitter(); - - private finalEditMode = false; - - public get showPreamble(): boolean { - return this.motion?.showPreamble; - } - - public get canChangeMetadata(): boolean { - return this.perms.isAllowed(`change_metadata`, this.motion); - } - - /** - * check if the 'final version edit mode' is active - * - * @returns true if active - */ - public get isFinalEdit(): boolean { - return this.finalEditMode; - } - - public get isParagraphBasedAmendment(): boolean { - return this.isExisting && this.motion.isParagraphBasedAmendment(); - } - - public get hasAttachments(): boolean { - return this.isExisting && this.motion?.hasAttachments(); - } - - public get isExisting(): boolean { - return this.motion instanceof ViewMotion; - } - - public get motionValues(): Partial { - return this.contentForm.value; - } - - public get hasCategories(): boolean { - return this.categoryRepo.getViewModelList().length > 0; - } - - /** - * Constant to identify the notification-message. - */ - public NOTIFICATION_EDIT_MOTION = `notifyEditMotion`; - - public readonly ChangeRecoMode = ChangeRecoMode; - - public readonly LineNumberingMode = LineNumberingMode; - - public contentForm!: UntypedFormGroup; - - public workflows: ViewMotionWorkflow[] = []; - - public categories: ViewMotionCategory[] = []; - - /** - * Indicates the currently highlighted line, if any. - */ - public highlightedLine!: number; - - public set canSaveParagraphBasedAmendment(can: boolean) { - this._canSaveParagraphBasedAmendment = can; - this.propagateChanges(); - } - - public set paragraphBasedAmendmentContent(content: { - amendment_paragraphs: { [paragraph_number: number]: UnsafeHtml }; - }) { - this._paragraphBasedAmendmentContent = content; - this.propagateChanges(); - } - - public participantSubscriptionConfig = getParticipantMinimalSubscriptionConfig(this.activeMeetingId); - - private titleFieldUpdateSubscription: Subscription; - - private _canSaveParagraphBasedAmendment = true; - private _paragraphBasedAmendmentContent: any = {}; - private _motionContent: any = {}; - private _initialState: any = {}; - - private _editSubscriptions: Subscription[] = []; - - private _motionNumbersSubject = new BehaviorSubject([]); - - public constructor( - protected override translate: TranslateService, - private fb: UntypedFormBuilder, - private dialog: MotionChangeRecommendationDialogService, - private route: ActivatedRoute, - private perms: MotionPermissionService, - private motionController: MotionControllerService, - public participantSortService: ParticipantListSortService - ) { - super(); - this.motionController - .getViewModelListObservable() - .subscribe(motions => this.updateMotionNumbersSubject(motions)); - } - - /** - * Click handler for attachments - * - * @param attachment the selected file - */ - public onClickAttachment(attachment: Mediafile): void { - window.open(attachment.url); - } - - /** - * Handler for upload errors - * - * @param error the error message passed by the upload component - */ - public showUploadError(error: any): void { - this.raiseError(error); - } - - /** - * get the formatted motion text from the repository. - * - * @returns formatted motion texts - */ - public getFormattedTextPlain(): string { - // Prevent this.sortedChangingObjects to be reordered from within formatMotion - let changes: ViewUnifiedChange[]; - if (this.changeRecoMode === ChangeRecoMode.Original) { - changes = []; - } else { - changes = Object.assign([], this.getAllTextChangingObjects()); - } - if (this.lineLength) { - const formattedText = this.motionFormatService.formatMotion({ - targetMotion: this.motion, - crMode: this.changeRecoMode, - changes, - lineLength: this.lineLength, - highlightedLine: this.highlightedLine, - firstLine: this.motion.firstLine - }); - return formattedText; - } else { - return this.motion.text; - } - } - - /** - * 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: MotionContentChangeRecommendationDialogComponentData = { - editChangeRecommendation: false, - newChangeRecommendation: true, - lineRange, - changeRecommendation: null, - firstLine: this.motion.firstLine - }; - if (this.motion.isParagraphBasedAmendment()) { - try { - const lineNumberedParagraphs = this.motionLineNumbering // - .getAllAmendmentParagraphsWithOriginalLineNumbers(this.motion, this.lineLength, false); - data.changeRecommendation = this.changeRecoRepo.createAmendmentChangeRecommendationTemplate( - this.motion, - lineNumberedParagraphs, - lineRange - ); - } catch (e) { - console.error(e); - return; - } - } else { - data.changeRecommendation = this.changeRecoRepo.createMotionChangeRecommendationTemplate( - this.motion, - lineRange, - this.lineLength - ); - } - this.dialog.openContentChangeRecommendationDialog(data); - } - - public getChangesForDiffMode(): ViewUnifiedChange[] { - return this.getAllChangingObjectsSorted().filter(change => { - if (this.showAllAmendments) { - return true; - } else { - return change.showInDiffView(); - } - }); - } - - public async createNewSubmitter(username: string): Promise { - const newUserObj = await this.createNewUser(username); - this.addNewUserToFormCtrl(newUserObj, `submitter_ids`); - } - - public async createNewSupporter(username: string): Promise { - const newUserObj = await this.createNewUser(username); - this.addNewUserToFormCtrl(newUserObj, `supporters_id`); - } - - public getDefaultWorkflowKeyOfSettingsByParagraph(_paragraph: number): keyof Settings { - let configKey: keyof Settings = `motions_default_workflow_id`; - if (!!this.route.snapshot.queryParams[`parent`]) { - configKey = `motions_default_amendment_workflow_id`; - } - return configKey; - } - - protected override onEnterEditMode(): void { - this.patchForm(); - this.initContentFormSubscription(); - this.propagateChanges(); - } - - /** - * Async load the values of the motion in the Form. - */ - protected patchForm(): void { - if (!this.contentForm) { - this.contentForm = this.createForm(); - } - if (this.isExisting) { - const contentPatch: { [key: string]: any } = {}; - Object.keys(this.contentForm.controls).forEach(ctrl => { - contentPatch[ctrl] = this.motion[ctrl]; - }); - - if (this.isParagraphBasedAmendment) { - this.contentForm.get(`text`)?.clearValidators(); // manually adjust validators - } - - this._initialState = deepCopy(contentPatch); - this.contentForm.patchValue(contentPatch); - } else { - const parentId = Number(this.route.snapshot.queryParams[`parent`]); - if (parentId && !Number.isNaN(parentId)) { - if (!this.titleFieldUpdateSubscription) { - this.titleFieldUpdateSubscription = this.repo - .getViewModelObservable(parentId) - .pipe( - map(parent => { - return { number: parent?.number, text: parent?.text }; - }), - distinctUntilChanged() - ) - .subscribe(data => { - if (!this.contentForm.get(`title`).value) { - const title = this.translate.instant(`Amendment to`) + ` ${data.number}`; - this.contentForm.patchValue({ - title: title - }); - this._motionContent[`title`] = title; - this.propagateChanges(); - } - if ( - !this.contentForm.get(`text`).value && - this.meetingSettingsService.instant(`motions_amendments_text_mode`) === `fulltext` - ) { - this.contentForm.patchValue({ - text: data.text - }); - this._motionContent[`text`] = data.text; - this.propagateChanges(); - } - }); - } - } - } - } - - protected override onInitTextBasedAmendment(): void { - this.patchForm(); - this.propagateChanges(); - } - - protected override getSubscriptions(): Subscription[] { - // since updates are usually not coming at the same time, every change to - // any subject has to mark the view for checking - if (this.motion) { - return [this.participantRepo.getViewModelListObservable().subscribe(() => this.cd.markForCheck())]; - } - return []; - } - - protected override onAfterInit(): void { - this.updateMotionNumbersSubject(); - } - - private updateMotionNumbersSubject(motions?: ViewMotion[]): void { - this._motionNumbersSubject.next( - (motions ?? this.motionController.getViewModelList()) - .filter( - motion => motion.number !== this.motion?.number && (!motion.id || motion.id !== this.motion?.id) - ) - .map(motion => motion.number) - ); - } - - private initContentFormSubscription(): void { - for (const subscription of this._editSubscriptions) { - subscription.unsubscribe(); - } - this._editSubscriptions = []; - for (const controlName of Object.keys(this.contentForm.controls)) { - this._editSubscriptions.push( - this.contentForm.get(controlName)!.valueChanges.subscribe(value => { - if (JSON.stringify(value) !== JSON.stringify(this._initialState[controlName])) { - this._motionContent[controlName] = value; - } else { - delete this._motionContent[controlName]; - } - this.propagateChanges(); - }) - ); - } - } - - private propagateChanges(): void { - setTimeout(() => { - this.formChanged.emit({ ...this._motionContent, ...this._paragraphBasedAmendmentContent }); - this.validStateChanged.emit(this.contentForm.valid && this._canSaveParagraphBasedAmendment); - }); - } - - private addNewUserToFormCtrl(newUserObj: RawUser, controlName: string): void { - const control = this.contentForm.get(controlName)!; - let currentSubmitters: number[] = control.value; - if (currentSubmitters?.length) { - currentSubmitters.push(newUserObj.id); - } else { - currentSubmitters = [newUserObj.id]; - } - control.setValue(currentSubmitters); - } - - private createNewUser(username: string): Promise { - return this.participantRepo.createFromString(username); - } - - private getAllTextChangingObjects(): ViewUnifiedChange[] { - return this.getAllChangingObjectsSorted().filter((obj: ViewUnifiedChange) => !obj.isTitleChange()); - } - - /** - * Creates the forms for the Motion and the MotionVersion - */ - private createForm(): UntypedFormGroup { - const motionFormControls: MotionFormControlsConfig = { - title: [``, Validators.required], - text: [``, this.isParagraphBasedAmendment ? null : Validators.required], - reason: [``, this.reasonRequired ? Validators.required : null], - category_id: [], - attachment_ids: [[]], - agenda_parent_id: [], - submitter_ids: [[]], - supporter_ids: [[]], - workflow_id: [+this.meetingSettingsService.instant(`motions_default_workflow_id`)], - tag_ids: [[]], - block_id: [], - parent_id: [], - modified_final_version: [``], - ...(this.canChangeMetadata && { - number: [ - ``, - isUniqueAmong(this._motionNumbersSubject, (a, b) => a === b, [``, null, undefined]) - ], - agenda_create: [``], - agenda_type: [``] - }) - }; - - return this.fb.group(motionFormControls); - } -} diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/motion-form/motion-form.component.html b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/motion-form/motion-form.component.html index 62dab32cba..8bc125d4c1 100644 --- a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/motion-form/motion-form.component.html +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/motion-form/motion-form.component.html @@ -4,33 +4,202 @@ [editMode]="true" [isSaveButtonEnabled]="canSave" [nav]="false" - [saveAction]="getSaveAction()" + [saveAction]="saveMotion()" (cancelEditEvent)="leaveEditMotion()" >
@if (motion && !newMotion && !vp.isMobile) {

{{ 'Motion' | translate }}

- } - @if (newMotion && !amendmentEdit) { + } @else if (newMotion && !amendmentEdit) {

{{ 'New motion' | translate }}

- } - @if (amendmentEdit) { + } @else if (amendmentEdit) {

{{ 'New amendment' | translate }}

}
@if (motion) { -
+
- +
+ + @if (newMotion) { +
+ @if (canChangeMetadata) { + + {{ 'Submitters' | translate }} + + + add + {{ 'Create user' | translate }} + + + + } +
+ } + +
+ + @if (!newMotion && canChangeMetadata) { +
+ + {{ 'Number' | translate }} + + {{ 'already exists' | translate }} + +
+ } + + +
+ + {{ 'Title' | translate }} + + {{ 'The title is required' | translate }} + +
+
+ + + @if (!isParagraphBasedAmendment) { + @if (preamble) { +

+ {{ preamble }} +

+ } + + @if ( + contentForm.get('text')?.invalid && + (contentForm.get('text')?.dirty || contentForm.get('text')?.touched) + ) { +
+ {{ 'This field is required.' | translate }} +
+ } + } @else { + + } + + +
+

+ {{ 'Reason' | translate }} +   + @if (reasonRequired) { + * + } +

+ + + @if ( + reasonRequired && + contentForm.get('reason')?.invalid && + (contentForm.get('reason')?.dirty || contentForm.get('reason')?.touched) + ) { +
+ {{ 'This field is required.' | translate }} +
+ } +
+ +
+ + @if (newMotion && hasCategories) { +
+ + {{ 'Category' | translate }} + + +
+ } + + +
+
+ +
+
+ + @if (canChangeMetadata) { + @if (newMotion) { +
+ +
+ } + + + @if (minSupporters) { +
+ + {{ 'Supporters' | translate }} + + + add + {{ 'Create user' | translate }} + + + +
+ } + + +
+ + {{ 'Workflow' | translate }} + + +
+ } +
+
diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/motion-form/motion-form.component.ts b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/motion-form/motion-form.component.ts index 3ab6a5bb71..bf4c5c9570 100644 --- a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/motion-form/motion-form.component.ts +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/motion-form/motion-form.component.ts @@ -3,36 +3,53 @@ import { ChangeDetectorRef, Component, HostListener, - OnDestroy, OnInit, ViewEncapsulation } from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; -import { BehaviorSubject, Observable, of } from 'rxjs'; -import { Id } from 'src/app/domain/definitions/key-types'; +import { BehaviorSubject, distinctUntilChanged, filter, firstValueFrom, map, Subscription } from 'rxjs'; +import { Id, UnsafeHtml } from 'src/app/domain/definitions/key-types'; import { HasSequentialNumber } from 'src/app/domain/interfaces'; +import { Mediafile } from 'src/app/domain/models/mediafiles/mediafile'; import { Motion } from 'src/app/domain/models/motions/motion'; -import { LineNumberingMode, PERSONAL_NOTE_ID } from 'src/app/domain/models/motions/motions.constants'; -import { Deferred } from 'src/app/infrastructure/utils/promises'; +import { RawUser } from 'src/app/gateways/repositories/users'; +import { deepCopy } from 'src/app/infrastructure/utils/transform-functions'; +import { isUniqueAmong } from 'src/app/infrastructure/utils/validators/is-unique-among'; import { BaseMeetingComponent } from 'src/app/site/pages/meetings/base/base-meeting.component'; import { ViewMotion } from 'src/app/site/pages/meetings/pages/motions'; -import { OperatorService } from 'src/app/site/services/operator.service'; +import { ParticipantControllerService } from 'src/app/site/pages/meetings/pages/participants/services/common/participant-controller.service'; import { ViewPortService } from 'src/app/site/services/view-port.service'; -import { PromptService } from 'src/app/ui/modules/prompt-dialog'; -import { AgendaItemControllerService } from '../../../../../../../agenda/services/agenda-item-controller.service/agenda-item-controller.service'; -import { MotionForwardDialogService } from '../../../../../../components/motion-forward-dialog/services/motion-forward-dialog.service'; +import { ParticipantListSortService } from '../../../../../../../participants/pages/participant-list/services/participant-list-sort/participant-list-sort.service'; +import { getParticipantMinimalSubscriptionConfig } from '../../../../../../../participants/participants.subscription'; +import { MotionCategoryControllerService } from '../../../../../../modules/categories/services'; +import { MotionWorkflowControllerService } from '../../../../../../modules/workflows/services'; +import { MOTION_DETAIL_SUBSCRIPTION } from '../../../../../../motions.subscription'; import { AmendmentControllerService } from '../../../../../../services/common/amendment-controller.service/amendment-controller.service'; import { MotionControllerService } from '../../../../../../services/common/motion-controller.service/motion-controller.service'; import { MotionPermissionService } from '../../../../../../services/common/motion-permission.service/motion-permission.service'; -import { MotionPdfExportService } from '../../../../../../services/export/motion-pdf-export.service/motion-pdf-export.service'; -import { AmendmentListFilterService } from '../../../../../../services/list/amendment-list-filter.service/amendment-list-filter.service'; -import { AmendmentListSortService } from '../../../../../../services/list/amendment-list-sort.service/amendment-list-sort.service'; -import { MotionListFilterService } from '../../../../../../services/list/motion-list-filter.service/motion-list-filter.service'; -import { MotionListSortService } from '../../../../../../services/list/motion-list-sort.service/motion-list-sort.service'; -import { MotionDetailViewService } from '../../../../services/motion-detail-view.service'; -import { MotionDetailViewOriginUrlService } from '../../../../services/motion-detail-view-originurl.service'; + +/** + * fields that are required for the motion form but are not part of any motion payload + */ +interface MotionFormFields { + // from update payload + modified_final_version: string; + // apparently from no payload + parent_id: string; + + // For agenda creations + agenda_parent_id: Id; + + // Motion + workflow_id: Id; +} + +type MotionFormControlsConfig = { [key in keyof MotionFormFields]?: any } & { [key in keyof Motion]?: any } & { + supporter_ids?: any; +}; @Component({ selector: `os-motion-form`, @@ -41,14 +58,9 @@ import { MotionDetailViewOriginUrlService } from '../../../../services/motion-de changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None }) -export class MotionFormComponent extends BaseMeetingComponent implements OnInit, OnDestroy { +export class MotionFormComponent extends BaseMeetingComponent implements OnInit { public readonly collection = ViewMotion.COLLECTION; - /** - * Determine if the motion is edited - */ - public editMotion = false; - /** * Determine if the motion is a new (unsent) amendment to another motion */ @@ -65,111 +77,99 @@ export class MotionFormComponent extends BaseMeetingComponent implements OnInit, */ public set motion(motion: ViewMotion) { this._motion = motion; - if (motion) { - this.init(); - } } public get motion(): ViewMotion { return this._motion; } - public temporaryMotion: any = {}; + public get canChangeMetadata(): boolean { + return this.perms.isAllowed(`change_metadata`, this.motion); + } - public canSave = false; + public get isParagraphBasedAmendment(): boolean { + return this.isExisting && this.motion.isParagraphBasedAmendment(); + } - /** - * preload the next motion for direct navigation - */ - public set nextMotion(motion: ViewMotion | null) { - this._nextMotion = motion; - this.cd.markForCheck(); + public get isExisting(): boolean { + return this.motion instanceof ViewMotion; } - public get nextMotion(): ViewMotion | null { - return this._nextMotion; + public get hasCategories(): boolean { + return this.categoryRepo.getViewModelList().length > 0; } /** - * preload the previous motion for direct navigation + * Constant to identify the notification-message. */ - public set previousMotion(motion: ViewMotion | null) { - this._previousMotion = motion; - this.cd.markForCheck(); - } + public NOTIFICATION_EDIT_MOTION = `notifyEditMotion`; - public get previousMotion(): ViewMotion | null { - return this._previousMotion; - } + public contentForm!: UntypedFormGroup; - public get showNavigateButtons(): boolean { - return !!this.previousMotion || !!this.nextMotion; + public set canSaveParagraphBasedAmendment(can: boolean) { + this._canSaveParagraphBasedAmendment = can; + this.propagateChanges(); } - public hasLoaded = new BehaviorSubject(false); + public set paragraphBasedAmendmentContent(content: { + amendment_paragraphs: { [paragraph_number: number]: UnsafeHtml }; + }) { + this._paragraphBasedAmendmentContent = content; + this.propagateChanges(); + } - private _nextMotion: ViewMotion | null = null; + public participantSubscriptionConfig = getParticipantMinimalSubscriptionConfig(this.activeMeetingId); - private _previousMotion: ViewMotion | null = null; + public preamble = ``; + public reasonRequired = false; + public minSupporters = 0; - /** - * Subject for (other) motions - */ - private _motionObserver: Observable = of([]); + public temporaryMotion: any = {}; - /** - * List of presorted motions. Filles by sort service - * and filter service. - * To navigate back and forth - */ - private _sortedMotions: ViewMotion[] = []; + public canSave = false; - /** - * The observable for the list of motions. Set in OnInit - */ - private _sortedMotionsObservable: Observable = of([]); + public hasLoaded = new BehaviorSubject(false); - private _motion: ViewMotion | null = null; - private _motionId: Id | null = null; - private _parentId: Id | null = null; + private titleFieldUpdateSubscription: Subscription; - private _hasModelSubscriptionInitiated = false; + private _canSaveParagraphBasedAmendment = true; + private _paragraphBasedAmendmentContent: any = {}; + private _motionContent: any = {}; + private _initialState: any = {}; - private _forwardingAvailable = false; + private _editSubscriptions: Subscription[] = []; - private _amendmentsInMainList = false; + private _motionNumbersSubject = new BehaviorSubject([]); - private _navigatedFromAmendmentList = false; + private _motion: ViewMotion | null = null; + private _motionId: Id | null = null; + private _parentId: Id | null = null; public constructor( protected override translate: TranslateService, public vp: ViewPortService, - public operator: OperatorService, - public perms: MotionPermissionService, + public participantRepo: ParticipantControllerService, + public participantSortService: ParticipantListSortService, + public categoryRepo: MotionCategoryControllerService, + public workflowRepo: MotionWorkflowControllerService, + private fb: UntypedFormBuilder, private route: ActivatedRoute, - public repo: MotionControllerService, - private viewService: MotionDetailViewService, - private promptService: PromptService, - private itemRepo: AgendaItemControllerService, - private motionSortService: MotionListSortService, - private motionFilterService: MotionListFilterService, - private motionForwardingService: MotionForwardDialogService, + private motionController: MotionControllerService, private amendmentRepo: AmendmentControllerService, - private amendmentSortService: AmendmentListSortService, - private amendmentFilterService: AmendmentListFilterService, - private cd: ChangeDetectorRef, - private pdfExport: MotionPdfExportService, - private originUrlService: MotionDetailViewOriginUrlService + private perms: MotionPermissionService, + private cd: ChangeDetectorRef ) { super(); - this.motionForwardingService.forwardingMeetingsAvailable().then(forwardingAvailable => { - this._forwardingAvailable = forwardingAvailable; - }); - - this.meetingSettingsService - .get(`motions_amendments_in_main_list`) - .subscribe(enabled => (this._amendmentsInMainList = enabled)); + this.subscriptions.push( + this.meetingSettingsService.get(`motions_preamble`).subscribe(value => (this.preamble = value)), + this.meetingSettingsService + .get(`motions_reason_required`) + .subscribe(value => (this.reasonRequired = value)), + this.meetingSettingsService + .get(`motions_supporters_min_amount`) + .subscribe(value => (this.minSupporters = value)) + ); } /** @@ -180,190 +180,72 @@ export class MotionFormComponent extends BaseMeetingComponent implements OnInit, this.subscriptions.push( this.activeMeetingIdService.meetingIdObservable.subscribe(() => { this.hasLoaded.next(false); + }), + this.vp.isMobileSubject.subscribe(() => { + this.cd.markForCheck(); }) ); } - /** - * Called during view destruction. - * Sends a notification to user editors of the motion was edited - */ - public override ngOnDestroy(): void { - super.ngOnDestroy(); - this.destroy(); - this.amendmentSortService.exitSortService(); - this.motionSortService.exitSortService(); - } - - public getSaveAction(): () => Promise { - return () => this.saveMotion(); - } - - /** - * Sets @var this._navigatedFromAmendmentList on navigation from either of both lists. - * Does nothing on navigation between two motions. - */ - private isNavigatedFromAmendments(): void { - const previousUrl = this.originUrlService.getPreviousUrl(); - if (!!previousUrl) { - if (previousUrl.endsWith(`amendments`)) { - this._navigatedFromAmendmentList = true; - } else if (previousUrl.endsWith(`motions`)) { - this._navigatedFromAmendmentList = false; - } - } - } - - public goToHistory(): void { - this.router.navigate([this.activeMeetingId!, `history`], { queryParams: { fqid: this.motion.fqid } }); - } - /** * In the ui are no distinct buttons for update or create. This is decided here. */ - public async saveMotion(event?: any): Promise { - const update = event || this.temporaryMotion; - if (this.newMotion) { - await this.createMotion(update); - } else { - await this.updateMotion(update, this.motion); - this.leaveEditMotion(); - } - } - - /** - * Trigger to delete the motion. - */ - public async deleteMotionButton(): Promise { - let title = this.translate.instant(`Are you sure you want to delete this motion? `); - let content = this.motion.getTitle(); - if (this.motion.amendments.length) { - title = this.translate.instant( - `Warning: Amendments exist for this motion. Are you sure you want to delete this motion regardless?` - ); - content = - `` + - this.translate.instant(`Motion`) + - ` ` + - this.motion.getTitle() + - `` + - `
` + - this.translate.instant(`Deleting this motion will also delete the amendments.`) + - `
` + - this.translate.instant(`List of amendments: `) + - `
` + - this.motion.amendments - .map(amendment => (amendment.number ? amendment.number : amendment.title)) - .join(`, `); - } - if (await this.promptService.open(title, content)) { - await this.repo.delete(this.motion); - this.router.navigate([this.activeMeetingId, `motions`]); - } + public saveMotion(event?: any): () => Promise { + return async () => { + const update = event || this.temporaryMotion; + if (this.newMotion) { + await this.createMotion(update); + } else { + await this.updateMotion(update, this.motion); + this.leaveEditMotion(); + } + }; } - /** - * Goes to the amendment creation wizard. Executed via click. - */ - public createAmendment(): void { - const amendmentTextMode = this.meetingSettingsService.instant(`motions_amendments_text_mode`); - if (amendmentTextMode === `paragraph`) { - this.router.navigate([`create-amendment`], { relativeTo: this.route }); + public leaveEditMotion(motion: HasSequentialNumber | null = this.motion): void { + if (motion?.sequential_number) { + this.router.navigate([this.activeMeetingId, `motions`, motion.sequential_number]); } else { - this.router.navigate([this.activeMeetingId, `motions`, `new-amendment`], { - relativeTo: this.route.snapshot.params[`relativeTo`], - queryParams: { parent: this.motion.id || null } - }); - } - } - - public async forwardMotionToMeetings(): Promise { - await this.motionForwardingService.forwardMotionsToMeetings(this.motion); - } - - public get showForwardButton(): boolean { - return !!this.motion.state?.allow_motion_forwarding && this._forwardingAvailable; - } - - public enterEditMotion(): void { - this.editMotion = true; - this.showMotionEditConflictWarningIfNecessary(); - } - - public leaveEditMotion(): void { - if (this.newMotion) { this.router.navigate([this.activeMeetingId, `motions`]); - } else { - this.editMotion = false; } } /** - * Navigates the user to the given ViewMotion + * Function to prevent automatically closing the window/tab, + * if the user is editing a motion. * - * @param motion target + * @param event The event object from 'onUnbeforeUnload'. */ - public navigateToMotion(motion: ViewMotion | null): void { - if (motion) { - this.router.navigate([this.activeMeetingId, `motions`, motion.sequential_number]); - // update the current motion - this.motion = motion; - this.setSurroundingMotions(); + @HostListener(`window:beforeunload`, [`$event`]) + public stopClosing(event: Event): void { + if (Object.keys(this._motionContent).length) { + event.returnValue = false; } } - /** - * Sets the previous and next motion. Sorts by the current sorting as used - * in the {@link MotionSortListService} or {@link AmendmentSortListService}, - * respectively - */ - public setSurroundingMotions(): void { - const indexOfCurrent = this._sortedMotions.findIndex(motion => motion === this.motion); - if (indexOfCurrent > 0) { - this.previousMotion = this.findNextSuitableMotion(indexOfCurrent, -1); - } else { - this.previousMotion = null; - } - if (indexOfCurrent > -1 && indexOfCurrent < this._sortedMotions.length - 1) { - this.nextMotion = this.findNextSuitableMotion(indexOfCurrent, 1); + public async onIdFound(id: Id | null): Promise { + this._motionId = id; + if (id) { + await this.loadMotionById(); } else { - this.nextMotion = null; + await this.initNewMotion(); } - } - /** - * Finds the next suitable motion. - * If @var this._amendmentsInMainList as well as @var this._navigatedFromAmendmentList collide - * iterates over the next or previous motions to find the first with lead motion. - * @param indexOfCurrent The index from the active motion. - * @param step Stepwidth to iterate eiter over the previous or next motions. - */ - private findNextSuitableMotion(indexOfCurrent: number, step: number): ViewMotion { - if (!this._amendmentsInMainList || !this._navigatedFromAmendmentList) { - return this._sortedMotions[indexOfCurrent + step]; - } + this.patchForm(); + this.initContentFormSubscription(); + this.propagateChanges(); + this.attachMotionNumbersSubject(); - for (let i = indexOfCurrent + step; 0 <= i && i <= this._sortedMotions.length - 1; i += step) { - if (!!this._sortedMotions[i].hasLeadMotion) { - return this._sortedMotions[i]; - } - } - return null; + this.hasLoaded.next(true); } /** - * Click handler for the pdf button + * Click handler for attachments + * + * @param attachment the selected file */ - public onDownloadPdf(): void { - this.pdfExport.exportSingleMotion(this.motion, { - lnMode: - this.viewService.currentLineNumberingMode === LineNumberingMode.Inside - ? LineNumberingMode.Outside - : this.viewService.currentLineNumberingMode, - crMode: this.viewService.currentChangeRecommendationMode, - // export all comment fields as well as personal note - comments: this.motion.usedCommentSectionIds.concat([PERSONAL_NOTE_ID]) - }); + public onClickAttachment(attachment: Mediafile): void { + window.open(attachment.url); } /** @@ -371,143 +253,151 @@ export class MotionFormComponent extends BaseMeetingComponent implements OnInit, * * @param error the error message passed by the upload component */ - public showUploadError(error: string): void { + public showUploadError(error: any): void { this.raiseError(error); } - /** - * Function to prevent automatically closing the window/tab, - * if the user is editing a motion. - * - * @param event The event object from 'onUnbeforeUnload'. - */ - @HostListener(`window:beforeunload`, [`$event`]) - public stopClosing(event: Event): void { - if (this.editMotion) { - event.returnValue = false; - } + public async createNewSubmitter(username: string): Promise { + const newUserObj = await this.createNewUser(username); + this.addNewUserToFormCtrl(newUserObj, `submitter_ids`); } - public addToAgenda(): void { - this.itemRepo.addToAgenda({}, this.motion).resolve().catch(this.raiseError); + public async createNewSupporter(username: string): Promise { + const newUserObj = await this.createNewUser(username); + this.addNewUserToFormCtrl(newUserObj, `supporters_id`); } - public removeFromAgenda(): void { - this.itemRepo.removeFromAgenda(this.motion.agenda_item_id!).catch(this.raiseError); + /** + * Creates a motion. Calls the "patchValues" function in the MotionObject + */ + public async createMotion(newMotionValues: Partial): Promise { + try { + let response: HasSequentialNumber; + if (this._parentId) { + response = await this.amendmentRepo.createTextBased({ + ...newMotionValues, + lead_motion_id: this._parentId + }); + } else { + response = (await this.motionController.create(newMotionValues))[0]; + } + this.leaveEditMotion(response); + } catch (e) { + this.raiseError(e); + } } - public onIdFound(id: Id | null): void { - if (this._motionId !== id) { - this.onRouteChanged(); + /** + * Async load the values of the motion in the Form. + */ + protected patchForm(): void { + if (!this.contentForm) { + this.contentForm = this.createForm(); } - this._motionId = id; - if (id) { - this.loadMotionById(); - } else { - this.initNewMotion(); + const contentPatch: { [key: string]: any } = {}; + Object.keys(this.contentForm.controls).forEach(ctrl => { + contentPatch[ctrl] = this.motion[ctrl]; + }); + + if (this.isParagraphBasedAmendment) { + this.contentForm.get(`text`)?.clearValidators(); // manually adjust validators } - this.hasLoaded.next(true); - } - private registerSubjects(): void { - this._motionObserver = this.repo.getViewModelListObservable(); - // since updates are usually not commig at the same time, every change to - // any subject has to mark the view for chekcing - this.subscriptions.push( - this._motionObserver.subscribe(() => { - this.cd.markForCheck(); - }) - ); + this._initialState = deepCopy(contentPatch); + this.contentForm.patchValue(contentPatch); + + if (this.amendmentEdit) { + const parentId = Number(this.route.snapshot.queryParams[`parent`]); + if (parentId && !Number.isNaN(parentId)) { + if (!this.titleFieldUpdateSubscription) { + this.titleFieldUpdateSubscription = this.motionController + .getViewModelObservable(parentId) + .pipe( + map(parent => { + return { number: parent?.number, text: parent?.text }; + }), + distinctUntilChanged() + ) + .subscribe(() => { + // TODO: Notify user that text changed + }); + this.subscriptions.push(this.titleFieldUpdateSubscription); + } + } + } } - private initNewMotion(): void { + private async initNewMotion(): Promise { // new motion super.setTitle(`New motion`); this.newMotion = true; - this.editMotion = true; this.motion = {} as any; if (this.route.snapshot.queryParams[`parent`]) { - this.initializeAmendment(); + await this.initializeAmendment(); } + + this.cd.markForCheck(); } - private loadMotionById(motionId: Id | null = this._motionId): void { - if (this._hasModelSubscriptionInitiated || !motionId) { - return; // already fired! + private async loadMotionById(motionId: Id | null = this._motionId): Promise { + await this.modelRequestService.waitSubscriptionReady(MOTION_DETAIL_SUBSCRIPTION); + const motion = await firstValueFrom(this.motionController.getViewModelObservable(motionId)); + if (motion) { + const title = motion.getTitle(); + super.setTitle(title); + this.motion = motion; + + this.newMotion = false; + this.showMotionEditConflictWarningIfNecessary(); } - this._hasModelSubscriptionInitiated = true; + + this.cd.markForCheck(); this.subscriptions.push( - this.repo.getViewModelObservable(motionId).subscribe(motion => { + this.motionController.getViewModelObservable(motionId).subscribe(motion => { if (motion) { - const title = motion.getTitle(); - super.setTitle(title); - this.motion = motion; - this.cd.markForCheck(); + // TODO: Check if relevant form fields were changed + // TODO: Maybe update pristine form fields + // TODO: Notify user that motion has changed } }) ); } - /** - * Using Shift, Alt + the arrow keys will navigate between the motions - * - * @param event has the key code - */ - @HostListener(`document:keydown`, [`$event`]) - public onKeyNavigation(event: KeyboardEvent): void { - if (event.key === `ArrowLeft` && event.altKey && event.shiftKey) { - this.navigateToMotion(this.previousMotion); - } - if (event.key === `ArrowRight` && event.altKey && event.shiftKey) { - this.navigateToMotion(this.nextMotion); - } - } - - /** - * Creates a motion. Calls the "patchValues" function in the MotionObject - */ - public async createMotion(newMotionValues: Partial): Promise { + private async updateMotion(newMotionValues: any, motion: ViewMotion): Promise { try { - let response: HasSequentialNumber; - if (this._parentId) { - response = await this.amendmentRepo.createTextBased({ - ...newMotionValues, - lead_motion_id: this._parentId - }); - } else { - response = (await this.repo.create(newMotionValues))[0]; - } - await this.navigateAfterCreation(response); + await this.motionController.update(newMotionValues, motion).resolve(); } catch (e) { this.raiseError(e); } } - private async updateMotion(newMotionValues: any, motion: ViewMotion): Promise { - await this.repo.update(newMotionValues, motion).resolve(); - } - - private async ensureParentIsAvailable(parentId: Id): Promise { - if (!this.repo.getViewModel(parentId)) { - const loaded = new Deferred(); - this.subscriptions.push( - this.repo.getViewModelObservable(parentId).subscribe(parent => { - if (parent && !loaded.wasResolved) { - loaded.resolve(); - } - }) + private async ensureParentIsAvailable(parentId: Id): Promise { + let motion: ViewMotion; + if (!(motion = this.motionController.getViewModel(parentId))) { + motion = await firstValueFrom( + this.motionController + .getViewModelObservable(parentId) + .pipe( + filter( + motion => + !!motion?.id && + (motion.text !== undefined || + this.meetingSettingsService.instant(`motions_amendments_text_mode`) !== `fulltext`) + ) + ) ); - return loaded; + console.log(`wait for`, parentId, motion.numberOrTitle); } + + return motion; } private async initializeAmendment(): Promise { const motion: any = {}; this._parentId = +this.route.snapshot.queryParams[`parent`] || null; this.amendmentEdit = true; - await this.ensureParentIsAvailable(this._parentId!); - const parentMotion = this.repo.getViewModel(this._parentId!); + const parentMotion = await this.ensureParentIsAvailable(this._parentId!); motion.lead_motion_id = this._parentId; if (parentMotion) { const defaultTitle = `${this.translate.instant(`Amendment to`)} ${parentMotion.numberOrTitle}`; @@ -521,52 +411,41 @@ export class MotionFormComponent extends BaseMeetingComponent implements OnInit, } } - /** - * Lifecycle routine for motions to initialize. - */ - private init(): void { - this.cd.reattach(); - - this.isNavigatedFromAmendments(); - this.registerSubjects(); - - // use the filter and the search service to get the current sorting - if (this.motion && this.motion.lead_motion_id && !this._amendmentsInMainList) { - // only use the amendments for this motion - this.amendmentSortService.initSorting(); - this.amendmentFilterService.initFilters( - this.amendmentRepo.getSortedViewModelListObservableFor( - { id: this.motion.lead_motion_id }, - this.amendmentSortService.repositorySortingKey - ) - ); - this._sortedMotionsObservable = this.amendmentFilterService.outputObservable; - } else { - this.motionSortService.initSorting(); - this.motionFilterService.initFilters( - this.repo.getSortedViewModelListObservable(this.motionSortService.repositorySortingKey) - ); - this._sortedMotionsObservable = this.motionFilterService.outputObservable; + private initContentFormSubscription(): void { + for (const subscription of this._editSubscriptions) { + subscription.unsubscribe(); } - - if (this._sortedMotionsObservable) { - this.subscriptions.push( - this._sortedMotionsObservable.subscribe(motions => { - if (motions) { - this._sortedMotions = motions; - this.setSurroundingMotions(); - } - }) - ); + this._editSubscriptions = []; + for (const controlName of Object.keys(this.contentForm.controls)) { + const subscription = this.contentForm.get(controlName)!.valueChanges.subscribe(value => { + if (JSON.stringify(value) !== JSON.stringify(this._initialState[controlName])) { + this._motionContent[controlName] = value; + } else { + delete this._motionContent[controlName]; + } + this.propagateChanges(); + }); + this._editSubscriptions.push(subscription); + this.subscriptions.push(subscription); } + } + private attachMotionNumbersSubject(): void { this.subscriptions.push( - /** - * Check for changes of the viewport subject changes - */ - this.vp.isMobileSubject.subscribe(() => { - this.cd.markForCheck(); - }) + this.motionController + .getViewModelListObservable() + .pipe( + map(motions => + motions + .filter( + motion => + motion.number !== this.motion?.number && + (!motion.id || motion.id !== this.motion?.id) + ) + .map(motion => motion.number) + ) + ) + .subscribe(this._motionNumbersSubject) ); } @@ -579,22 +458,54 @@ export class MotionFormComponent extends BaseMeetingComponent implements OnInit, } } - /** - * Lifecycle routine for motions to get destroyed. - */ - private destroy(): void { - this._hasModelSubscriptionInitiated = false; - this.cleanSubscriptions(); - this.viewService.reset(); - this.cd.detach(); + private propagateChanges(): void { + this.canSave = this.contentForm.valid && this._canSaveParagraphBasedAmendment; + this.temporaryMotion = { ...this._motionContent, ...this._paragraphBasedAmendmentContent }; + } + + private addNewUserToFormCtrl(newUserObj: RawUser, controlName: string): void { + const control = this.contentForm.get(controlName)!; + let currentSubmitters: number[] = control.value; + if (currentSubmitters?.length) { + currentSubmitters.push(newUserObj.id); + } else { + currentSubmitters = [newUserObj.id]; + } + control.setValue(currentSubmitters); } - private onRouteChanged(): void { - this.destroy(); - this.init(); + private createNewUser(username: string): Promise { + return this.participantRepo.createFromString(username); } - private async navigateAfterCreation(motion: HasSequentialNumber): Promise { - this.router.navigate([this.activeMeetingId, `motions`, motion!.sequential_number]); + /** + * Creates the forms for the Motion and the MotionVersion + */ + private createForm(): UntypedFormGroup { + const motionFormControls: MotionFormControlsConfig = { + title: [``, Validators.required], + text: [``, this.isParagraphBasedAmendment ? null : Validators.required], + reason: [``, this.meetingSettingsService.instant(`motions_reason_required`) ? Validators.required : null], + category_id: [], + attachment_ids: [[]], + agenda_parent_id: [], + submitter_ids: [[]], + supporter_ids: [[]], + workflow_id: [+this.meetingSettingsService.instant(`motions_default_workflow_id`)], + tag_ids: [[]], + block_id: [], + parent_id: [], + modified_final_version: [``], + ...(this.canChangeMetadata && { + number: [ + ``, + isUniqueAmong(this._motionNumbersSubject, (a, b) => a === b, [``, null, undefined]) + ], + agenda_create: [``], + agenda_type: [``] + }) + }; + + return this.fb.group(motionFormControls); } } diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/paragraph-based-amendment-editor/paragraph-based-amendment-editor.component.html b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/paragraph-based-amendment-editor/paragraph-based-amendment-editor.component.html new file mode 100644 index 0000000000..7a54375382 --- /dev/null +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/paragraph-based-amendment-editor/paragraph-based-amendment-editor.component.html @@ -0,0 +1,31 @@ +@if (contentForm) { +
+ @for (paragraph of selectedParagraphs; track paragraph.paragraphNo) { +
+

+ @if (paragraph.lineFrom >= paragraph.lineTo - 1) { + {{ 'Line' | translate }} {{ paragraph.lineFrom }} + } @else { + + {{ 'Line' | translate }} {{ paragraph.lineFrom }} - {{ paragraph.lineTo - 1 }} + + } +

+ + @if (isControlInvalid(paragraph.paragraphNo)) { +
+ {{ 'This field is required.' | translate }} +
+ } +
+ } + @for (paragraph of brokenParagraphs; track paragraph) { +
+ + {{ 'This paragraph does not exist in the main motion anymore:' | translate }} + +
+
+ } +
+} diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/paragraph-based-amendment-editor/paragraph-based-amendment-editor.component.scss b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/paragraph-based-amendment-editor/paragraph-based-amendment-editor.component.scss new file mode 100644 index 0000000000..218d7e307a --- /dev/null +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/paragraph-based-amendment-editor/paragraph-based-amendment-editor.component.scss @@ -0,0 +1,8 @@ +.alert-inconsistency { + color: red; + font-style: italic; +} + +.motion-text > os-motion-detail-diff { + margin-left: -40px; +} diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/paragraph-based-amendment-editor/paragraph-based-amendment-editor.component.spec.ts b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/paragraph-based-amendment-editor/paragraph-based-amendment-editor.component.spec.ts new file mode 100644 index 0000000000..7e43e0715a --- /dev/null +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/paragraph-based-amendment-editor/paragraph-based-amendment-editor.component.spec.ts @@ -0,0 +1,24 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ParagraphBasedAmendmentEditorComponent } from './paragraph-based-amendment-editor.component'; + +xdescribe(`ParagraphBasedAmendmentEditorComponent`, () => { + let component: ParagraphBasedAmendmentEditorComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ParagraphBasedAmendmentEditorComponent] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ParagraphBasedAmendmentEditorComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it(`should create`, () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/paragraph-based-amendment-editor/paragraph-based-amendment-editor.component.ts b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/paragraph-based-amendment-editor/paragraph-based-amendment-editor.component.ts new file mode 100644 index 0000000000..fee6fc18be --- /dev/null +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/paragraph-based-amendment-editor/paragraph-based-amendment-editor.component.ts @@ -0,0 +1,104 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Output } from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { TranslateService } from '@ngx-translate/core'; + +import { ParagraphToChoose } from '../../../../../../services/common/motion-line-numbering.service/motion-line-numbering.service'; +import { BaseMotionDetailChildComponent } from '../../../../base/base-motion-detail-child.component'; + +interface ParagraphBasedAmendmentContent { + amendment_paragraphs: { [paragraph_number: number]: any }; + selected_paragraphs: ParagraphToChoose[]; + broken_paragraphs: string[]; +} + +const CONTENT_FORM_SUBSCRIPTION_NAME = `contentForm`; + +@Component({ + selector: `os-paragraph-based-amendment-editor`, + templateUrl: `./paragraph-based-amendment-editor.component.html`, + styleUrls: [`./paragraph-based-amendment-editor.component.scss`], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ParagraphBasedAmendmentEditorComponent extends BaseMotionDetailChildComponent { + @Output() + public formChanged = new EventEmitter(); + + @Output() + public validStateChanged = new EventEmitter(); + + public selectedParagraphs: ParagraphToChoose[] = []; + + public brokenParagraphs: string[] = []; + + public contentForm: UntypedFormGroup | null = null; + + public constructor( + protected override translate: TranslateService, + private fb: UntypedFormBuilder + ) { + super(); + } + + protected override onAfterSetMotion(): void { + if (this.contentForm) { + this.contentForm = null; + } + const contentPatch = this.createForm(); + this.contentForm = this.fb.group(contentPatch.amendment_paragraphs); + this.selectedParagraphs = contentPatch.selected_paragraphs; + this.brokenParagraphs = contentPatch.broken_paragraphs; + this.propagateChanges(); + } + + public isControlInvalid(paragraphNumber: number): boolean { + const control = this.contentForm!.get(paragraphNumber.toString())!; + return control.invalid && (control.dirty || control.touched); + } + + private createForm(): ParagraphBasedAmendmentContent { + const contentPatch: ParagraphBasedAmendmentContent = { + selected_paragraphs: [], + amendment_paragraphs: {}, + broken_paragraphs: [] + }; + const leadMotion = this.motion?.lead_motion; + // Hint: lineLength is sometimes not loaded yet when this form is initialized; + // This doesn't hurt as long as patchForm is called when editing mode is started, i.e., later. + if (leadMotion && this.lineLength) { + const paragraphsToChoose = this.motionLineNumbering.getParagraphsToChoose(leadMotion, this.lineLength); + + paragraphsToChoose.forEach((paragraph: ParagraphToChoose, paragraphNo: number): void => { + const amendmentParagraph = this.motion.amendment_paragraph_text(paragraphNo); + if (amendmentParagraph !== null && amendmentParagraph !== undefined) { + contentPatch.selected_paragraphs.push(paragraph); + contentPatch.amendment_paragraphs[paragraphNo] = [amendmentParagraph]; + } + }); + // If the motion has been shortened after the amendment has been created, we will show the paragraphs + // of the amendment as read-only + for ( + let paragraphNo = paragraphsToChoose.length; + paragraphNo < this.motion.amendment_paragraph_numbers.length; + paragraphNo++ + ) { + if (this.motion.amendment_paragraph_text(paragraphNo) !== null) { + contentPatch.broken_paragraphs.push(this.motion.amendment_paragraph_text(paragraphNo)!); + } + } + } + return contentPatch; + } + + private propagateChanges(): void { + this.updateSubscription( + CONTENT_FORM_SUBSCRIPTION_NAME, + this.contentForm!.valueChanges.subscribe(value => { + if (value) { + this.formChanged.emit({ amendment_paragraphs: value }); + this.validStateChanged.emit(this.contentForm!.valid); + this.cd.markForCheck(); + } + }) + ); + } +} diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/motion-form.module.ts b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/motion-form.module.ts index 945451352b..b695668acd 100644 --- a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/motion-form.module.ts +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/motion-form.module.ts @@ -1,21 +1,43 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MatCardModule } from '@angular/material/card'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; import { OpenSlidesTranslationModule } from 'src/app/site/modules/translations'; +import { AgendaContentObjectFormModule } from 'src/app/site/pages/meetings/modules/meetings-component-collector/agenda-content-object-form/agenda-content-object-form.module'; +import { AttachmentControlModule } from 'src/app/site/pages/meetings/modules/meetings-component-collector/attachment-control'; import { DetailViewModule } from 'src/app/site/pages/meetings/modules/meetings-component-collector/detail-view/detail-view.module'; +import { DirectivesModule } from 'src/app/ui/directives'; +import { EditorModule } from 'src/app/ui/modules/editor'; import { HeadBarModule } from 'src/app/ui/modules/head-bar'; +import { SearchSelectorModule } from 'src/app/ui/modules/search-selector'; +import { PipesModule } from 'src/app/ui/pipes'; import { MotionFormComponent } from './components/motion-form/motion-form.component'; +import { ParagraphBasedAmendmentEditorComponent } from './components/paragraph-based-amendment-editor/paragraph-based-amendment-editor.component'; import { MotionFormRoutingModule } from './motion-form-routing.module'; @NgModule({ - declarations: [MotionFormComponent], + declarations: [MotionFormComponent, ParagraphBasedAmendmentEditorComponent], imports: [ CommonModule, MotionFormRoutingModule, + DirectivesModule, + PipesModule, + AgendaContentObjectFormModule, HeadBarModule, DetailViewModule, + AttachmentControlModule, + EditorModule, + SearchSelectorModule, MatCardModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + FormsModule, + ReactiveFormsModule, OpenSlidesTranslationModule.forChild() ] }) diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-view/motion-view.component.ts b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-view/motion-view.component.ts index cd3aae8a14..97e0b97374 100644 --- a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-view/motion-view.component.ts +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-view/motion-view.component.ts @@ -220,7 +220,7 @@ export class MotionViewComponent extends BaseMeetingComponent implements OnInit, if (amendmentTextMode === `paragraph`) { this.router.navigate([`create-amendment`], { relativeTo: this.route }); } else { - this.router.navigate([this.activeMeetingId, `motions`, `new-amendment`], { + this.router.navigate([this.activeMeetingId, `motions`, `new`], { relativeTo: this.route.snapshot.params[`relativeTo`], queryParams: { parent: this.motion.id || null } }); diff --git a/client/src/app/site/services/openslides-router.service.ts b/client/src/app/site/services/openslides-router.service.ts index ed096766ec..0edb80c623 100644 --- a/client/src/app/site/services/openslides-router.service.ts +++ b/client/src/app/site/services/openslides-router.service.ts @@ -208,6 +208,9 @@ export class OpenSlidesRouterService { } private _toParamMap(currentRoute: ActivatedRouteSnapshot, paramMap: { [paramName: string]: any }): void { + for (const [key, value] of Object.entries(currentRoute.queryParams ?? {})) { + paramMap[key] = value; + } for (const [key, value] of Object.entries(currentRoute.params ?? {})) { paramMap[key] = value; } diff --git a/client/src/app/ui/modules/editor/components/editor/editor.component.ts b/client/src/app/ui/modules/editor/components/editor/editor.component.ts index ffc81ea839..592c859991 100644 --- a/client/src/app/ui/modules/editor/components/editor/editor.component.ts +++ b/client/src/app/ui/modules/editor/components/editor/editor.component.ts @@ -244,7 +244,7 @@ export class EditorComponent extends BaseFormControlComponent implements public override ngOnDestroy(): void { super.ngOnDestroy(); - this.editor.destroy(); + this.editor?.destroy(); } public updateColorSets(): void {