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) {
+
+}
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 {