diff --git a/client/src/app/gateways/repositories/base-repository.ts b/client/src/app/gateways/repositories/base-repository.ts index 818478c76d..8caaaa9bc4 100644 --- a/client/src/app/gateways/repositories/base-repository.ts +++ b/client/src/app/gateways/repositories/base-repository.ts @@ -721,6 +721,10 @@ export abstract class BaseRepository { + obj.viewModelUpdateTimestamp = Date.now(); + return Reflect.set(obj, ...args); } }); this._createViewModelPipes.forEach(fn => fn(viewModel)); diff --git a/client/src/app/infrastructure/utils/subscription-map.ts b/client/src/app/infrastructure/utils/subscription-map.ts index d896436cc1..c53b9bb507 100644 --- a/client/src/app/infrastructure/utils/subscription-map.ts +++ b/client/src/app/infrastructure/utils/subscription-map.ts @@ -31,6 +31,10 @@ export class SubscriptionMap { this._subscriptions = {}; } + public size(): number { + return Object.keys(this._subscriptions).length; + } + private nextRandomId(): string { const id = Math.floor(Math.random() * (900000 - 1) + 100000); return id.toString(); diff --git a/client/src/app/site/base/base-view-model.ts b/client/src/app/site/base/base-view-model.ts index 780d88bf06..28fa4b26ee 100644 --- a/client/src/app/site/base/base-view-model.ts +++ b/client/src/app/site/base/base-view-model.ts @@ -23,6 +23,8 @@ export interface ViewModelConstructor { * Base class for view models. */ export abstract class BaseViewModel implements DetailNavigable { + public viewModelUpdateTimestamp = Date.now(); + public get fqid(): Fqid { return this.getModel().fqid; } diff --git a/client/src/app/site/modules/global-headbar/components/global-headbar/global-headbar.component.scss b/client/src/app/site/modules/global-headbar/components/global-headbar/global-headbar.component.scss index 7f01975836..88912048f0 100644 --- a/client/src/app/site/modules/global-headbar/components/global-headbar/global-headbar.component.scss +++ b/client/src/app/site/modules/global-headbar/components/global-headbar/global-headbar.component.scss @@ -44,3 +44,26 @@ margin-top: -3px; transform: scale(0.75); } + +@keyframes train { + 0% { + right: 0; + } + 99% { + right: 100%; + } + 100% { + right: 100%; + display: none; + } +} + +.global-headbar.train:after { + content: '🚂 🚃 🚃 🚃'; + position: absolute; + overflow: hidden; + animation-name: train; + animation-duration: 15s; + animation-fill-mode: forwards; + animation-iteration-count: 1; +} diff --git a/client/src/app/site/pages/meetings/modules/meetings-component-collector/detail-view/components/detail-view/detail-view.component.ts b/client/src/app/site/pages/meetings/modules/meetings-component-collector/detail-view/components/detail-view/detail-view.component.ts index 080633d160..6a36959d8a 100644 --- a/client/src/app/site/pages/meetings/modules/meetings-component-collector/detail-view/components/detail-view/detail-view.component.ts +++ b/client/src/app/site/pages/meetings/modules/meetings-component-collector/detail-view/components/detail-view/detail-view.component.ts @@ -7,10 +7,11 @@ import { OnInit, Output } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { marker as _ } from '@colsen1991/ngx-translate-extract-marker'; import { Subscription } from 'rxjs'; import { Collection, Id } from 'src/app/domain/definitions/key-types'; +import { ActiveMeetingService } from 'src/app/site/pages/meetings/services/active-meeting.service'; import { ActiveMeetingIdService } from 'src/app/site/pages/meetings/services/active-meeting-id.service'; import { SequentialNumberMappingService } from 'src/app/site/pages/meetings/services/sequential-number-mapping.service'; @@ -44,29 +45,38 @@ export class DetailViewComponent implements OnInit { private _shouldShowContent = false; private _loading = true; private _id!: Id; + private _sequential_number!: number; private _subscriptionMap: { [name: string]: Subscription } = {}; public constructor( private sequentialNumberMappingService: SequentialNumberMappingService, + private activeMeetingService: ActiveMeetingService, private activeMeetingIdService: ActiveMeetingIdService, private route: ActivatedRoute, + private router: Router, private cd: ChangeDetectorRef ) {} public ngOnInit(): void { - this.activeMeetingIdService.meetingIdObservable.subscribe(() => this.onMeetingChanged()); + this.activeMeetingService.meetingIdObservable.subscribe(id => this.onMeetingChanged(id)); } - private onMeetingChanged(): void { - const subscription = this.route.params.subscribe(params => { - this.parseSequentialNumber(params); + private onMeetingChanged(meetingId: Id): void { + this.deleteSubscription(ROUTE_SUBSCRIPTION_NAME); + this.activeMeetingService.ensureActiveMeetingIsAvailable().then(() => { + const subscription = this.route.params.subscribe(params => { + if (this.activeMeetingIdService.parseUrlMeetingId(this.router.url) === meetingId) { + this.parseSequentialNumber(params); + } + }); + this.updateSubscription(ROUTE_SUBSCRIPTION_NAME, subscription); }); - this.updateSubscription(ROUTE_SUBSCRIPTION_NAME, subscription); } private parseSequentialNumber(params: { id?: string }): void { const sequentialNumber = +(params.id ?? 0); + this._sequential_number = sequentialNumber; if (!sequentialNumber && params.id === undefined) { // it must be another subroute, like creating a new one this._shouldShowContent = true; @@ -76,12 +86,12 @@ export class DetailViewComponent implements OnInit { const config = { collection: this.collection, sequentialNumber, - meetingId: this.activeMeetingIdService.meetingId! + meetingId: this.activeMeetingService.meetingId! }; this.sequentialNumberMappingService.getIdBySequentialNumber(config).then(id => { this._loading = false; - if (id !== undefined) { + if (id !== undefined && this._sequential_number === sequentialNumber) { if (id) { if (this._id !== id) { this._id = id; @@ -109,4 +119,11 @@ export class DetailViewComponent implements OnInit { } this._subscriptionMap[subscriptionName] = subscription; } + + private deleteSubscription(subscriptionName: string): void { + if (this._subscriptionMap[subscriptionName]) { + this._subscriptionMap[subscriptionName].unsubscribe(); + this._subscriptionMap[subscriptionName] = null; + } + } } diff --git a/client/src/app/site/pages/meetings/modules/poll/base/base-poll.component.ts b/client/src/app/site/pages/meetings/modules/poll/base/base-poll.component.ts index a9efe6b7eb..6e194ceda4 100644 --- a/client/src/app/site/pages/meetings/modules/poll/base/base-poll.component.ts +++ b/client/src/app/site/pages/meetings/modules/poll/base/base-poll.component.ts @@ -21,6 +21,11 @@ export abstract class BasePollComponent exten return this._poll; } + protected set poll(poll: ViewPoll) { + this._poll = poll; + this.onAfterUpdatePoll(poll); + } + public pollStateActions = { [PollState.Created]: { icon: `play_arrow`, @@ -110,8 +115,7 @@ export abstract class BasePollComponent exten this.subscriptions.push( this.repo.getViewModelObservable(this._id).subscribe(poll => { if (poll) { - this._poll = poll; - this.onAfterUpdatePoll(poll); + this.poll = poll; } }) ); diff --git a/client/src/app/site/pages/meetings/pages/motions/modules/change-recommendations/services/motion-change-recommendation-controller.service/motion-change-recommendation-controller.service.ts b/client/src/app/site/pages/meetings/pages/motions/modules/change-recommendations/services/motion-change-recommendation-controller.service/motion-change-recommendation-controller.service.ts index 38ddf58d2f..ac5abdb4d7 100644 --- a/client/src/app/site/pages/meetings/pages/motions/modules/change-recommendations/services/motion-change-recommendation-controller.service/motion-change-recommendation-controller.service.ts +++ b/client/src/app/site/pages/meetings/pages/motions/modules/change-recommendations/services/motion-change-recommendation-controller.service/motion-change-recommendation-controller.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { map, Observable } from 'rxjs'; +import { distinctUntilChanged, map, Observable } from 'rxjs'; import { Id } from 'src/app/domain/definitions/key-types'; import { Identifiable } from 'src/app/domain/interfaces'; import { MotionChangeRecommendation } from 'src/app/domain/models/motions/motion-change-recommendation'; @@ -47,7 +47,13 @@ export class MotionChangeRecommendationControllerService extends BaseMeetingCont */ public getChangeRecosOfMotionObservable(motionId: Id): Observable { return this.getViewModelListObservable().pipe( - map((recos: ViewMotionChangeRecommendation[]) => recos.filter(reco => reco.motion_id === motionId)) + map((recos: ViewMotionChangeRecommendation[]) => recos.filter(reco => reco.motion_id === motionId)), + distinctUntilChanged( + (prev, curr) => + prev?.length === curr?.length && + Math.max(...prev.map(e => e.viewModelUpdateTimestamp)) === + Math.max(...curr.map(e => e.viewModelUpdateTimestamp)) + ) ); } diff --git a/client/src/app/site/pages/meetings/pages/motions/modules/motion-poll/components/motion-poll/motion-poll.component.ts b/client/src/app/site/pages/meetings/pages/motions/modules/motion-poll/components/motion-poll/motion-poll.component.ts index aafcf0eb0b..ac07c0e55a 100644 --- a/client/src/app/site/pages/meetings/pages/motions/modules/motion-poll/components/motion-poll/motion-poll.component.ts +++ b/client/src/app/site/pages/meetings/pages/motions/modules/motion-poll/components/motion-poll/motion-poll.component.ts @@ -7,6 +7,7 @@ import { BasePollComponent } from 'src/app/site/pages/meetings/modules/poll/base import { OperatorService } from 'src/app/site/services/operator.service'; import { VotingPrivacyWarningDialogService } from '../../../../../../modules/poll/modules/voting-privacy-dialog/services/voting-privacy-warning-dialog.service'; +import { ViewPoll } from '../../../../../polls'; import { MotionPollService } from '../../services'; import { MotionPollPdfService } from '../../services/motion-poll-pdf.service/motion-poll-pdf.service'; @@ -16,6 +17,11 @@ import { MotionPollPdfService } from '../../services/motion-poll-pdf.service/mot styleUrls: [`./motion-poll.component.scss`] }) export class MotionPollComponent extends BasePollComponent { + @Input() + public set pollViewModel(poll: ViewPoll) { + this.poll = poll; + } + @Input() public set pollId(id: Id) { this.initializePoll(id); diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/base/base-motion-detail-child.component.ts b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/base/base-motion-detail-child.component.ts index d0063ecc24..dad8b88219 100644 --- a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/base/base-motion-detail-child.component.ts +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/base/base-motion-detail-child.component.ts @@ -1,26 +1,24 @@ -import { Directive, inject, Input } from '@angular/core'; +import { ChangeDetectorRef, Directive, inject, Input } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; -import { filter, Subscription } from 'rxjs'; -import { ChangeRecoMode, LineNumberingMode } from 'src/app/domain/models/motions/motions.constants'; +import { filter, Observable, Subscription } from 'rxjs'; import { BaseMeetingComponent } from 'src/app/site/pages/meetings/base/base-meeting.component'; -import { ViewMotion, ViewMotionChangeRecommendation } from 'src/app/site/pages/meetings/pages/motions'; -import { ParticipantControllerService } from 'src/app/site/pages/meetings/pages/participants/services/common/participant-controller.service/participant-controller.service'; +import { ViewMotion } from 'src/app/site/pages/meetings/pages/motions'; import { MotionCategoryControllerService } from '../../../modules/categories/services'; import { MotionChangeRecommendationControllerService } from '../../../modules/change-recommendations/services'; import { ViewUnifiedChange } from '../../../modules/change-recommendations/view-models/view-unified-change'; import { MotionBlockControllerService } from '../../../modules/motion-blocks/services'; import { TagControllerService } from '../../../modules/tags/services'; -import { MotionWorkflowControllerService } from '../../../modules/workflows/services/motion-workflow-controller.service/motion-workflow-controller.service'; import { AmendmentControllerService } from '../../../services/common/amendment-controller.service'; import { MotionControllerService } from '../../../services/common/motion-controller.service/motion-controller.service'; import { MotionFormatService } from '../../../services/common/motion-format.service/motion-format.service'; import { MotionLineNumberingService } from '../../../services/common/motion-line-numbering.service/motion-line-numbering.service'; -import { MotionDetailServiceCollectorService } from '../services/motion-detail-service-collector.service/motion-detail-service-collector.service'; import { MotionDetailViewService } from '../services/motion-detail-view.service'; @Directive() export abstract class BaseMotionDetailChildComponent extends BaseMeetingComponent { + protected cd: ChangeDetectorRef = inject(ChangeDetectorRef); + @Input() public set motion(motion: ViewMotion) { const previousMotion = this._motion; @@ -29,55 +27,14 @@ export abstract class BaseMotionDetailChildComponent extends BaseMeetingComponen this.doUpdate(); } - if (!Object.keys(previousMotion || {}).length && Object.keys(motion).length) { - this.onInitTextBasedAmendment(); // Assuming that it's an amendment - } - this.onAfterSetMotion(previousMotion, motion); + this.cd.markForCheck(); } public get motion(): ViewMotion { return this._motion!; } - @Input() - public newMotion = false; - - @Input() - public set editMotion(isEditing: boolean) { - this._isEditing = isEditing; - if (isEditing) { - this.onEnterEditMode(); - } - } - - public get editMotion(): boolean { - return this._isEditing; - } - - public get lineNumberingMode(): LineNumberingMode { - return this.viewService.currentLineNumberingMode; - } - - public get changeRecoMode(): ChangeRecoMode { - return this.viewService.currentChangeRecommendationMode; - } - - public get hasChangingObjects(): boolean { - if (this.sortedChangingObjects !== null) { - return this.sortedChangingObjects.length > 0; - } - - return ( - (this.changeRecommendations && this.changeRecommendations.length > 0) || - (this.amendments && this.amendments.filter(amendment => amendment.isParagraphBasedAmendment()).length > 0) - ); - } - - public get parent(): ViewMotion | null { - return this.motion?.lead_motion || null; - } - /** * Whether to show all amendments in the text, not only the ones with the apropriate state */ @@ -85,66 +42,34 @@ export abstract class BaseMotionDetailChildComponent extends BaseMeetingComponen return this.viewService.currentShowAllAmendmentsState; } - /////////////////////////////////////////////// - /////// Getter to repos & services - /////////////////////////////////////////////// - - protected get repo(): MotionControllerService { - return this.motionServiceCollector.motionRepo; - } - - public get categoryRepo(): MotionCategoryControllerService { - return this.motionServiceCollector.categoryRepo; - } - - public get workflowRepo(): MotionWorkflowControllerService { - return this.motionServiceCollector.workflowRepo; - } - - public get participantRepo(): ParticipantControllerService { - return this.motionServiceCollector.participantRepo; - } - - protected get amendmentRepo(): AmendmentControllerService { - return this.motionServiceCollector.amendmentRepo; + public get showAllAmendments$(): Observable { + return this.viewService.showAllAmendmentsStateSubject; } - protected get blockRepo(): MotionBlockControllerService { - return this.motionServiceCollector.blockRepo; - } - - protected get tagRepo(): TagControllerService { - return this.motionServiceCollector.tagRepo; - } - - protected get changeRecoRepo(): MotionChangeRecommendationControllerService { - return this.motionServiceCollector.changeRecoRepo; - } + /////////////////////////////////////////////// + /////// Repos & services + /////////////////////////////////////////////// - protected get motionLineNumbering(): MotionLineNumberingService { - return this.motionServiceCollector.motionLineNumbering; - } + public categoryRepo = inject(MotionCategoryControllerService); - protected get motionFormatService(): MotionFormatService { - return this.motionServiceCollector.motionFormatService; - } + protected repo = inject(MotionControllerService); + protected amendmentRepo = inject(AmendmentControllerService); + protected blockRepo = inject(MotionBlockControllerService); + protected tagRepo = inject(TagControllerService); + protected changeRecoRepo = inject(MotionChangeRecommendationControllerService); + protected motionLineNumbering = inject(MotionLineNumberingService); + protected motionFormatService = inject(MotionFormatService); + protected viewService = inject(MotionDetailViewService); - protected get viewService(): MotionDetailViewService { - return this.motionServiceCollector.motionViewService; - } + protected override translate = inject(TranslateService); /////////////////////////////////////////////// /////// Settings variables /////////////////////////////////////////////// - public multipleParagraphsAllowed = false; - public reasonRequired = false; - public minSupporters = 0; - public preamble = ``; - public showReferringMotions = false; - public showSequentialNumber = false; - protected lineLength = 0; - protected sortedChangingObjects: ViewUnifiedChange[] | null = null; + protected get lineLength(): number { + return this.meetingSettingsService.instant(`motions_line_length`); + } /////////////////////////////////////////////// /////////////////////////////////////////////// @@ -152,49 +77,15 @@ export abstract class BaseMotionDetailChildComponent extends BaseMeetingComponen /** * All change recommendations to this motion */ - public changeRecommendations: ViewUnifiedChange[] = []; - /** - * Value for os-motion-detail-diff: when this is set, that component scrolls to the given change - */ - public scrollToChange: ViewUnifiedChange | null = null; - - protected amendments: ViewMotion[] = []; - - private _isEditing = false; - private _motion: ViewMotion | null = null; - - protected override translate = inject(TranslateService); - protected motionServiceCollector = inject(MotionDetailServiceCollectorService); - - /** - * In the original version, a change-recommendation-annotation has been clicked - * -> Go to the diff view and scroll to the change recommendation - */ - public gotoChangeRecommendation(changeRecommendation: ViewUnifiedChange): void { - this.scrollToChange = changeRecommendation; - this.viewService.changeRecommendationModeSubject.next(ChangeRecoMode.Diff); + public get changeRecommendations$(): Observable { + return this.changeRecoRepo.getChangeRecosOfMotionObservable(this.motion.id).pipe(filter(value => !!value)); } - protected getAllChangingObjectsSorted(): ViewUnifiedChange[] { - if (!this.sortedChangingObjects) { - this.sortedChangingObjects = this.motionLineNumbering.recalcUnifiedChanges( - this.lineLength, - this.changeRecommendations as ViewMotionChangeRecommendation[], - this.amendments - ); - } - return this.sortedChangingObjects!; + protected get amendments$(): Observable { + return this.amendmentRepo.getViewModelListObservableFor(this.motion).pipe(filter(value => !!value)); } - /** - * Function called when the edit-mode is set to `true` - */ - protected onEnterEditMode(): void {} - - /** - * Function called when a new motion is passed and it's an text-based amendment - */ - protected onInitTextBasedAmendment(): void {} + private _motion: ViewMotion | null = null; /** * Function called after all eventual updates whenever the motion setter is called @@ -216,60 +107,11 @@ export abstract class BaseMotionDetailChildComponent extends BaseMeetingComponen } private init(): void { - this.subscriptions.push( - ...this.getSharedSubscriptionsToSettings(), - ...this.getSharedSubscriptionsToRepositories(), - ...this.getSubscriptions() - ); + this.subscriptions.push(...this.getSubscriptions()); this.onAfterInit(); } private destroy(): void { this.cleanSubscriptions(); } - - private getSharedSubscriptionsToRepositories(): Subscription[] { - return [ - this.changeRecoRepo - .getChangeRecosOfMotionObservable(this.motion.id) - .pipe(filter(value => !!value)) - .subscribe(changeRecos => { - this.changeRecommendations = changeRecos; - this.sortedChangingObjects = null; - }), - this.amendmentRepo - .getViewModelListObservableFor(this.motion) - .pipe(filter(value => !!value)) - .subscribe((amendments: ViewMotion[]): void => { - this.amendments = amendments; - this.motionLineNumbering.resetAmendmentChangeRecoListeners(amendments); - this.sortedChangingObjects = null; - }) - ]; - } - - private getSharedSubscriptionsToSettings(): Subscription[] { - return [ - this.meetingSettingsService.get(`motions_line_length`).subscribe(lineLength => { - this.lineLength = lineLength; - this.sortedChangingObjects = null; - }), - this.meetingSettingsService - .get(`motions_reason_required`) - .subscribe(required => (this.reasonRequired = required)), - this.meetingSettingsService - .get(`motions_supporters_min_amount`) - .subscribe(value => (this.minSupporters = value)), - this.meetingSettingsService.get(`motions_preamble`).subscribe(value => (this.preamble = value)), - this.meetingSettingsService.get(`motions_amendments_multiple_paragraphs`).subscribe(allowed => { - this.multipleParagraphsAllowed = allowed; - }), - this.meetingSettingsService - .get(`motions_show_referring_motions`) - .subscribe(show => (this.showReferringMotions = show)), - this.meetingSettingsService - .get(`motions_show_sequential_number`) - .subscribe(shown => (this.showSequentialNumber = shown)) - ]; - } } diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-comments/motion-comments.component.html b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-comments/motion-comments.component.html deleted file mode 100644 index a04d7d34f1..0000000000 --- a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-comments/motion-comments.component.html +++ /dev/null @@ -1,9 +0,0 @@ -@for (section of sections; track trackById(index, section); let index = $index) { - -} diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-content/motion-content.component.html b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-content/motion-content.component.html deleted file mode 100644 index 71e16c496a..0000000000 --- a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-content/motion-content.component.html +++ /dev/null @@ -1,252 +0,0 @@ - -@if (!editMotion && !motion?.isParagraphBasedAmendment()) { - - @if (showPreamble && changeRecoMode !== ChangeRecoMode.Diff) { - - {{ preamble }} - - } - @if (changeRecoMode !== ChangeRecoMode.Diff && !isFinalEdit) { - - } - @if (changeRecoMode === ChangeRecoMode.Diff) { - - } -} - -
- - @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?.attachment_meeting_mediafiles; 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/components/motion-content/motion-content.component.ts b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-content/motion-content.component.ts deleted file mode 100644 index 833713c967..0000000000 --- a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-content/motion-content.component.ts +++ /dev/null @@ -1,443 +0,0 @@ -import { ChangeDetectorRef, 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; - attachment_mediafile_ids?: any; -}; - -@Component({ - selector: `os-motion-content`, - templateUrl: `./motion-content.component.html`, - styleUrls: [`./motion-content.component.scss`] -}) -export class MotionContentComponent 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 cd: ChangeDetectorRef, - 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.contentForm.controls[`attachment_mediafile_ids`]) { - contentPatch[`attachment_mediafile_ids`] = this.motion.attachment_meeting_mediafiles?.map( - file => file.mediafile_id - ); - } - - 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_mediafile_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/components/motion-detail-view/motion-detail-view.component.html b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-detail-view/motion-detail-view.component.html deleted file mode 100644 index 00eea62b48..0000000000 --- a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-detail-view/motion-detail-view.component.html +++ /dev/null @@ -1,241 +0,0 @@ - - @if (hasLoaded | async) { - - -
- @if (motion && !newMotion) { -

- @if (!vp.isMobile) { - {{ 'Motion' | translate }} - } - - @if (!vp.isMobile) { -   - } - @if (!editMotion) { - {{ motion.number }} - } -

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

{{ 'New motion' | translate }}

- } - @if (amendmentEdit) { -

{{ 'New amendment' | translate }}

- } -
- - @if (!editMotion && showNavigateButtons) { -
- - -
- } - - - @if (motion) { - - } - - - - - - - - - - -
- @if (motion && !motion.agenda_item_id) { - - } - @if (motion && motion.agenda_item_id) { - - } -
- -
- -
- -
- -
- @if (perms.isAllowed('update', motion) || perms.isAllowed('manage')) { - - } - - @if (perms.isAllowed('update', motion)) { - - } - - @if (perms.isAllowed('manage')) { - - } -
-
- @if (motion) { -
- - @if (!editMotion) { -
- -
- } - @if (editMotion) { -
- - - - - -
- } - @if (!editMotion) { - @if (vp.isMobile) { -
- - @if (!newMotion) { -
- -
- } - - - - - - - - - - @if (!operator.isAnonymous) { - - } -
- } @else { - @if (motion || newMotion) { -
-
- - @if (!newMotion) { -
- -
- } - - @if (!operator.isAnonymous) { - - } -
-
- - - - - - -
-
- } - } - } -
- } - } -
- - -
- -
-
- - - @if (!newMotion && !editMotion) { - - } - - diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-detail-view/motion-detail-view.component.ts b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-detail-view/motion-detail-view.component.ts deleted file mode 100644 index 441e0a28c0..0000000000 --- a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-detail-view/motion-detail-view.component.ts +++ /dev/null @@ -1,600 +0,0 @@ -import { - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - HostListener, - OnDestroy, - OnInit, - ViewEncapsulation -} from '@angular/core'; -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 { HasSequentialNumber } from 'src/app/domain/interfaces'; -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 { 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 { 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 { 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'; - -@Component({ - selector: `os-motion-detail-view`, - templateUrl: `./motion-detail-view.component.html`, - styleUrls: [`./motion-detail-view.component.scss`], - changeDetection: ChangeDetectionStrategy.OnPush, - encapsulation: ViewEncapsulation.None -}) -export class MotionDetailViewComponent extends BaseMeetingComponent implements OnInit, OnDestroy { - 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 - */ - public amendmentEdit = false; - - /** - * Determine if the motion is new - */ - public newMotion = false; - - /** - * Sets the motions, e.g. via an autoupdate. Reload important things here: - * - Reload the recommendation. Not changed with autoupdates, but if the motion is loaded this needs to run. - */ - public set motion(motion: ViewMotion) { - this._motion = motion; - if (motion) { - this.init(); - } - } - - public get motion(): ViewMotion { - return this._motion; - } - - public temporaryMotion: any = {}; - - public canSave = false; - - /** - * preload the next motion for direct navigation - */ - public set nextMotion(motion: ViewMotion | null) { - this._nextMotion = motion; - this.cd.markForCheck(); - } - - public get nextMotion(): ViewMotion | null { - return this._nextMotion; - } - - /** - * preload the previous motion for direct navigation - */ - public set previousMotion(motion: ViewMotion | null) { - this._previousMotion = motion; - this.cd.markForCheck(); - } - - public get previousMotion(): ViewMotion | null { - return this._previousMotion; - } - - public get showNavigateButtons(): boolean { - return !!this.previousMotion || !!this.nextMotion; - } - - public hasLoaded = new BehaviorSubject(false); - - private _nextMotion: ViewMotion | null = null; - - private _previousMotion: ViewMotion | null = null; - - /** - * Subject for (other) motions - */ - private _motionObserver: Observable = of([]); - - /** - * List of presorted motions. Filles by sort service - * and filter service. - * To navigate back and forth - */ - private _sortedMotions: ViewMotion[] = []; - - /** - * The observable for the list of motions. Set in OnInit - */ - private _sortedMotionsObservable: Observable = of([]); - - private _motion: ViewMotion | null = null; - private _motionId: Id | null = null; - private _parentId: Id | null = null; - - private _hasModelSubscriptionInitiated = false; - - private _forwardingAvailable = false; - - private _amendmentsInMainList = false; - - private _navigatedFromAmendmentList = false; - - public constructor( - protected override translate: TranslateService, - public vp: ViewPortService, - public operator: OperatorService, - public perms: MotionPermissionService, - 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 amendmentRepo: AmendmentControllerService, - private amendmentSortService: AmendmentListSortService, - private amendmentFilterService: AmendmentListFilterService, - private cd: ChangeDetectorRef, - private pdfExport: MotionPdfExportService, - private originUrlService: MotionDetailViewOriginUrlService - ) { - super(); - - this.motionForwardingService.forwardingMeetingsAvailable().then(forwardingAvailable => { - this._forwardingAvailable = forwardingAvailable; - }); - - this.meetingSettingsService - .get(`motions_amendments_in_main_list`) - .subscribe(enabled => (this._amendmentsInMainList = enabled)); - } - - /** - * Init. - * Sets all required subjects and fills in the required information - */ - public ngOnInit(): void { - this.subscriptions.push( - this.activeMeetingIdService.meetingIdObservable.subscribe(() => { - this.hasLoaded.next(false); - }) - ); - } - - /** - * 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`]); - } - } - - /** - * 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 }); - } 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 - * - * @param motion target - */ - 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(); - } - } - - /** - * 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); - } else { - this.nextMotion = null; - } - } - - /** - * 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]; - } - - 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; - } - - /** - * Click handler for the pdf button - */ - 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]) - }); - } - - /** - * Handler for upload errors - * - * @param error the error message passed by the upload component - */ - public showUploadError(error: string): 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 addToAgenda(): void { - this.itemRepo.addToAgenda({}, this.motion).resolve().catch(this.raiseError); - } - - public removeFromAgenda(): void { - this.itemRepo.removeFromAgenda(this.motion.agenda_item_id!).catch(this.raiseError); - } - - public onIdFound(id: Id | null): void { - if (this._motionId !== id) { - this.onRouteChanged(); - } - this._motionId = id; - if (id) { - this.loadMotionById(); - } else { - this.initNewMotion(); - } - 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(); - }) - ); - } - - private initNewMotion(): void { - // new motion - super.setTitle(`New motion`); - this.newMotion = true; - this.editMotion = true; - this.motion = {} as any; - if (this.route.snapshot.queryParams[`parent`]) { - this.initializeAmendment(); - } - } - - private loadMotionById(motionId: Id | null = this._motionId): void { - if (this._hasModelSubscriptionInitiated || !motionId) { - return; // already fired! - } - this._hasModelSubscriptionInitiated = true; - - this.subscriptions.push( - this.repo.getViewModelObservable(motionId).subscribe(motion => { - if (motion) { - const title = motion.getTitle(); - super.setTitle(title); - this.motion = motion; - this.cd.markForCheck(); - } - }) - ); - } - - /** - * 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 { - 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); - } 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(); - } - }) - ); - return loaded; - } - } - - 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!); - motion.lead_motion_id = this._parentId; - if (parentMotion) { - const defaultTitle = `${this.translate.instant(`Amendment to`)} ${parentMotion.numberOrTitle}`; - motion.title = defaultTitle; - motion.category_id = parentMotion.category_id; - const amendmentTextMode = this.meetingSettingsService.instant(`motions_amendments_text_mode`); - if (amendmentTextMode === `fulltext`) { - motion.text = parentMotion.text; - } - this.motion = motion; - } - } - - /** - * 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; - } - - if (this._sortedMotionsObservable) { - this.subscriptions.push( - this._sortedMotionsObservable.subscribe(motions => { - if (motions) { - this._sortedMotions = motions; - this.setSurroundingMotions(); - } - }) - ); - } - - this.subscriptions.push( - /** - * Check for changes of the viewport subject changes - */ - this.vp.isMobileSubject.subscribe(() => { - this.cd.markForCheck(); - }) - ); - } - - private showMotionEditConflictWarningIfNecessary(): void { - if (this.motion.amendments?.filter(amend => amend.isParagraphBasedAmendment()).length > 0) { - const msg = this.translate.instant( - `Warning: Amendments exist for this motion. Editing this text will likely impact them negatively. Particularily, amendments might become unusable if the paragraph they affect is deleted.` - ); - this.raiseWarning(msg); - } - } - - /** - * Lifecycle routine for motions to get destroyed. - */ - private destroy(): void { - this._hasModelSubscriptionInitiated = false; - this.cleanSubscriptions(); - this.viewService.reset(); - this.cd.detach(); - } - - private onRouteChanged(): void { - this.destroy(); - this.init(); - } - - private async navigateAfterCreation(motion: HasSequentialNumber): Promise { - this.router.navigate([this.activeMeetingId, `motions`, motion!.sequential_number]); - } -} 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..6059c5fb77 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,17 @@ 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`]; + 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/components/motion-manage-polls/motion-manage-polls.component.ts b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-manage-polls/motion-manage-polls.component.ts deleted file mode 100644 index d6a95d9c0c..0000000000 --- a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-manage-polls/motion-manage-polls.component.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Component, Input } from '@angular/core'; -import { Id } from 'src/app/domain/definitions/key-types'; -import { PollControllerService } from 'src/app/site/pages/meetings/modules/poll/services/poll-controller.service'; -import { ViewMotion } from 'src/app/site/pages/meetings/pages/motions'; - -import { MotionPollDialogService } from '../../../../modules/motion-poll/services/motion-poll-dialog.service'; - -@Component({ - selector: `os-motion-manage-polls`, - templateUrl: `./motion-manage-polls.component.html`, - styleUrls: [`./motion-manage-polls.component.scss`] -}) -export class MotionManagePollsComponent { - @Input() - public motion!: ViewMotion; - - @Input() - public hideAdd: boolean; - - public constructor( - private pollDialog: MotionPollDialogService, - private pollController: PollControllerService - ) {} - - public onEditPoll(id: Id): void { - const viewPoll = this.pollController.getViewModel(id)!; - this.pollDialog.open(viewPoll); - } -} diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-manage-title/motion-manage-title.component.scss b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-manage-title/motion-manage-title.component.scss deleted file mode 100644 index 791e9b5b7f..0000000000 --- a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-manage-title/motion-manage-title.component.scss +++ /dev/null @@ -1,45 +0,0 @@ -.title-line { - display: flex; - - .motion-title { - position: relative; - z-index: 1; - - // Grab the left padding of the parent element to catch hover-events for the :before element - margin-left: -20px; - padding-left: 20px; - - .change-title { - position: relative; - width: 0; - height: 0; - } - - .change-title:before { - position: absolute; - top: 18px; - left: -17px; - display: none; - cursor: pointer; - content: ''; - width: 16px; - height: 16px; - background: url('data:image/svg+xml;utf8,'); - background-size: 16px 16px; - } - - &:hover .change-title:before { - display: block; - } - - .title-change-indicator { - background-color: #0333ff; - position: absolute; - width: 4px; - height: 32px; - left: 10px; - top: 5px; - cursor: pointer; - } - } -} diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-paragraphbased-amendment/motion-paragraphbased-amendment.component.html b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-paragraphbased-amendment/motion-paragraphbased-amendment.component.html deleted file mode 100644 index e032367467..0000000000 --- a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-paragraphbased-amendment/motion-paragraphbased-amendment.component.html +++ /dev/null @@ -1,126 +0,0 @@ -@if (!editMotion) { -
- - @if (amendmentLines) { -
- @if (amendmentLines.length === 0) { -
- @if (motion.lead_motion) { - {{ 'No changes at the text.' | translate }} - } - @if (!motion.lead_motion) { - {{ 'The parent motion is not available.' | translate }} - } -
- } - @if (amendmentErrorMessage) { -
- -
- } - @if (motion.lead_motion && changeRecoMode !== ChangeRecoMode.Diff && !isFinalEdit) { - @for (paragraph of getAmendmentParagraphs(); track $index) { -
- @if (!showAmendmentContext) { -

- {{ getAmendmentParagraphLinesTitle(paragraph) }} -

- } - @if ( - lineNumberingMode === LineNumberingMode.Outside && - (changeRecoMode === ChangeRecoMode.Original || - changeRecoMode === ChangeRecoMode.Changed) - ) { - - } - @if ( - lineNumberingMode !== LineNumberingMode.Outside || - !( - changeRecoMode !== ChangeRecoMode.Original && - changeRecoMode !== ChangeRecoMode.Changed - ) - ) { -
- } - -
- } - } - @if (changeRecoMode === ChangeRecoMode.Diff) { - - } -
- } - @if (!amendmentLines) { -
- - {{ 'There is an error with this amendment. Please edit it manually.' | translate }} - -
- } -
- - @if (changeRecoMode === ChangeRecoMode.Original || changeRecoMode === ChangeRecoMode.Changed) { -
- @if (motion && motion.isParagraphBasedAmendment() && motion.lead_motion) { - - {{ 'Show entire motion text' | translate }} - - } -
- } -} - - -@if (editMotion) { -
- @for (paragraph of selectedParagraphs; track paragraph) { -
-

- @if (paragraph.lineFrom >= paragraph.lineTo - 1) { - {{ 'Line' | translate }} {{ paragraph.lineFrom }} - } - @if (paragraph.lineFrom < paragraph.lineTo - 1) { - - {{ '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/components/motion-paragraphbased-amendment/motion-paragraphbased-amendment.component.scss b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-paragraphbased-amendment/motion-paragraphbased-amendment.component.scss deleted file mode 100644 index c1aac0d443..0000000000 --- a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-paragraphbased-amendment/motion-paragraphbased-amendment.component.scss +++ /dev/null @@ -1,4 +0,0 @@ -.alert-inconsistency { - color: red; - font-style: italic; -} diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-paragraphbased-amendment/motion-paragraphbased-amendment.component.spec.ts b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-paragraphbased-amendment/motion-paragraphbased-amendment.component.spec.ts deleted file mode 100644 index f66c6cb4ae..0000000000 --- a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-paragraphbased-amendment/motion-paragraphbased-amendment.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { MotionParagraphbasedAmendmentComponent } from './motion-paragraphbased-amendment.component'; - -xdescribe(`MotionParagraphbasedAmendmentComponent`, () => { - let component: MotionParagraphbasedAmendmentComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - declarations: [MotionParagraphbasedAmendmentComponent] - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(MotionParagraphbasedAmendmentComponent); - 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/components/motion-paragraphbased-amendment/motion-paragraphbased-amendment.component.ts b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-paragraphbased-amendment/motion-paragraphbased-amendment.component.ts deleted file mode 100644 index e7f6a7b826..0000000000 --- a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-paragraphbased-amendment/motion-paragraphbased-amendment.component.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, Output } from '@angular/core'; -import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; -import { TranslateService } from '@ngx-translate/core'; -import { UnsafeHtml } from 'src/app/domain/definitions/key-types'; -import { ChangeRecoMode, LineNumberingMode } from 'src/app/domain/models/motions/motions.constants'; -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 { DiffLinesInParagraph } from '../../../../definitions/index'; -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[]; -} - -@Component({ - selector: `os-motion-paragraphbased-amendment`, - templateUrl: `./motion-paragraphbased-amendment.component.html`, - styleUrls: [`./motion-paragraphbased-amendment.component.scss`], - changeDetection: ChangeDetectionStrategy.OnPush -}) -export class MotionParagraphbasedAmendmentComponent extends BaseMotionDetailChildComponent { - public readonly LineNumberingMode = LineNumberingMode; - public readonly ChangeRecoMode = ChangeRecoMode; - - @Input() - public changesForDiffMode: ViewUnifiedChange[] = []; - - @Input() - public highlightedLine!: number; - - @Input() - public isFinalEdit = false; - - @Output() - public createChangeRecommendation = new EventEmitter(); - - @Output() - public formChanged = new EventEmitter(); - - @Output() - public validStateChanged = new EventEmitter(); - - public showAmendmentContext = false; - - public selectedParagraphs: ParagraphToChoose[] = []; - - public brokenParagraphs: string[] = []; - - public contentForm: UntypedFormGroup | null = null; - - public amendmentErrorMessage: string | null = null; - - public get amendmentLines(): DiffLinesInParagraph[] | null { - return this.motion?.changedAmendmentLines; - } - - public constructor( - protected override translate: TranslateService, - private fb: UntypedFormBuilder, - private cd: ChangeDetectorRef - ) { - super(); - } - - /** - * This returns the plain HTML of a changed area in an amendment, including its context, - * for the purpose of piping it into . - * This component works with plain HTML, hence we are composing plain HTML here, too. - * - * @param {DiffLinesInParagraph} paragraph - * @returns {string} - * - * TODO: Seems to be directly duplicated in the slide - */ - public getAmendmentDiffTextWithContext(paragraph: DiffLinesInParagraph): UnsafeHtml { - return ( - `
${paragraph.textPre}
` + - `
${paragraph.text}
` + - `
${paragraph.textPost}
` - ); - } - - /** - * If `this.motion` is an amendment, this returns the list of all changed paragraphs. - * - * @returns {DiffLinesInParagraph[]} - */ - public getAmendmentParagraphs(): DiffLinesInParagraph[] { - try { - this.amendmentErrorMessage = null; - return this.motion?.getAmendmentParagraphLines(ChangeRecoMode.Changed, this.showAmendmentContext) || []; - } catch (e: any) { - this.amendmentErrorMessage = e.toString(); - return []; - } - } - - public getAmendmentParagraphLinesTitle(paragraph: DiffLinesInParagraph): string { - return this.motion?.getParagraphTitleByParagraph(paragraph) || ``; - } - - public isControlInvalid(paragraphNumber: number): boolean { - const control = this.contentForm!.get(paragraphNumber.toString())!; - return control.invalid && (control.dirty || control.touched); - } - - protected override onEnterEditMode(): 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(); - } - - 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) { - contentPatch.selected_paragraphs.push(paragraph); - contentPatch.amendment_paragraphs[paragraphNo] = [amendmentParagraph, Validators.required]; - } - }); - // 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( - `contentForm`, - 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/components/paragraph-based-amendment/paragraph-based-amendment.component.html b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/paragraph-based-amendment/paragraph-based-amendment.component.html deleted file mode 100644 index 7dcd9a2a56..0000000000 --- a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/paragraph-based-amendment/paragraph-based-amendment.component.html +++ /dev/null @@ -1,148 +0,0 @@ -@if (!editMotion) { -
- - @if (amendmentLines) { -
- @if (amendmentLines.length === 0) { -
- @if (motion.lead_motion) { - {{ 'No changes at the text.' | translate }} - } - @if (!motion.lead_motion) { - {{ 'The parent motion is not available.' | translate }} - } -
- } - @if (amendmentErrorMessage) { -
- -
- } - @if (motion.lead_motion && !isFinalEdit) { - @if (changeRecoMode === ChangeRecoMode.Diff) { - - } - @for (paragraph of getAmendmentParagraphs(); track $index) { - - @if (changeRecoMode === ChangeRecoMode.Diff) { - - } - } - } - @if (changeRecoMode === ChangeRecoMode.Diff && (!motion.lead_motion || isFinalEdit)) { - - } -
- } - @if (!amendmentLines) { -
- - {{ 'There is an error with this amendment. Please edit it manually.' | translate }} - -
- } -
- - @if (changeRecoMode === ChangeRecoMode.Original || changeRecoMode === ChangeRecoMode.Changed) { -
- @if (motion && motion.isParagraphBasedAmendment() && motion.lead_motion) { - - {{ 'Show entire motion text' | translate }} - - } -
- } -} - - -@if (editMotion && contentForm) { -
- @for (paragraph of selectedParagraphs; track paragraph) { -
-

- @if (paragraph.lineFrom >= paragraph.lineTo - 1) { - {{ 'Line' | translate }} {{ paragraph.lineFrom }} - } - @if (paragraph.lineFrom < paragraph.lineTo - 1) { - - {{ '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/motion-detail-routing.module.ts b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/motion-detail-routing.module.ts index 82df86e0c8..d7a45cd08c 100644 --- a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/motion-detail-routing.module.ts +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/motion-detail-routing.module.ts @@ -2,9 +2,7 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; import { Permission } from 'src/app/domain/definitions/permission'; -import { AmendmentCreateWizardComponent } from './components/amendment-create-wizard/amendment-create-wizard.component'; import { MotionDetailComponent } from './components/motion-detail/motion-detail.component'; -import { MotionDetailViewComponent } from './components/motion-detail-view/motion-detail-view.component'; const routes: Routes = [ { @@ -12,38 +10,13 @@ const routes: Routes = [ component: MotionDetailComponent, children: [ { - path: `new`, - component: MotionDetailViewComponent, + path: ``, + loadChildren: () => import(`./pages/motion-form/motion-form.module`).then(m => m.MotionFormModule), data: { meetingPermissions: [Permission.motionCanCreate] } }, - { - path: `edit`, - data: { meetingPermissions: [Permission.motionCanManage] }, - children: [ - { - path: `:id`, - component: MotionDetailViewComponent - } - ] - }, - { - path: `new-amendment`, - component: MotionDetailViewComponent, - data: { meetingPermissions: [Permission.motionCanCreateAmendments] } - }, { path: `:id`, - children: [ - { - path: ``, - pathMatch: `full`, - component: MotionDetailViewComponent - }, - { - path: `create-amendment`, - component: AmendmentCreateWizardComponent - } - ] + loadChildren: () => import(`./pages/motion-view/motion-view.module`).then(m => m.MotionViewModule) } ] } diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/motion-detail.module.ts b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/motion-detail.module.ts index d458eee21a..251eaa2718 100644 --- a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/motion-detail.module.ts +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/motion-detail.module.ts @@ -13,9 +13,8 @@ import { MatInputModule } from '@angular/material/input'; import { MatListModule } from '@angular/material/list'; import { MatMenuModule } from '@angular/material/menu'; import { MatProgressBarModule } from '@angular/material/progress-bar'; -import { MatRadioModule } from '@angular/material/radio'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSelectModule } from '@angular/material/select'; -import { MatStepperModule } from '@angular/material/stepper'; import { MatTooltipModule } from '@angular/material/tooltip'; import { RouterModule } from '@angular/router'; import { NgxMaterialTimepickerModule } from 'ngx-material-timepicker'; @@ -43,38 +42,35 @@ import { MotionForwardDialogModule } from '../../components/motion-forward-dialo import { MotionPollModule } from '../../modules/motion-poll'; import { MotionsExportModule } from '../../services/export/motions-export.module'; import { MotionsListServiceModule } from '../../services/list/motions-list-service.module'; -import { AmendmentCreateWizardComponent } from './components/amendment-create-wizard/amendment-create-wizard.component'; -import { MotionAddPollButtonComponent } from './components/motion-add-poll-button/motion-add-poll-button.component'; -import { MotionCommentComponent } from './components/motion-comment/motion-comment.component'; -import { MotionCommentsComponent } from './components/motion-comments/motion-comments.component'; -import { MotionContentComponent } from './components/motion-content/motion-content.component'; import { MotionDetailComponent } from './components/motion-detail/motion-detail.component'; -import { MotionDetailDiffComponent } from './components/motion-detail-diff/motion-detail-diff.component'; -import { MotionDetailDiffSummaryComponent } from './components/motion-detail-diff-summary/motion-detail-diff-summary.component'; -import { MotionDetailOriginalChangeRecommendationsComponent } from './components/motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component'; -import { MotionDetailViewComponent } from './components/motion-detail-view/motion-detail-view.component'; -import { MotionExtensionFieldComponent } from './components/motion-extension-field/motion-extension-field.component'; -import { MotionFinalVersionComponent } from './components/motion-final-version/motion-final-version.component'; -import { MotionHighlightFormComponent } from './components/motion-highlight-form/motion-highlight-form.component'; -import { MotionManageMotionMeetingUsersComponent } from './components/motion-manage-motion-meeting-users/motion-manage-motion-meeting-users.component'; -import { MotionManagePollsComponent } from './components/motion-manage-polls/motion-manage-polls.component'; -import { MotionManageTimestampComponent } from './components/motion-manage-timestamp/motion-manage-timestamp.component'; -import { MotionManageTitleComponent } from './components/motion-manage-title/motion-manage-title.component'; -import { MotionMetaDataComponent } from './components/motion-meta-data/motion-meta-data.component'; -import { MotionParagraphbasedAmendmentComponent } from './components/motion-paragraphbased-amendment/motion-paragraphbased-amendment.component'; -import { MotionPersonalNoteComponent } from './components/motion-personal-note/motion-personal-note.component'; -import { ParagraphBasedAmendmentComponent } from './components/paragraph-based-amendment/paragraph-based-amendment.component'; import { MotionDetailDirectivesModule } from './modules/directives/motion-detail-directives.module'; import { MotionChangeRecommendationDialogModule } from './modules/motion-change-recommendation-dialog/motion-change-recommendation-dialog.module'; import { MotionDetailRoutingModule } from './motion-detail-routing.module'; +import { MotionAddPollButtonComponent } from './pages/motion-view/components/motion-add-poll-button/motion-add-poll-button.component'; +import { MotionCommentComponent } from './pages/motion-view/components/motion-comment/motion-comment.component'; +import { MotionCommentsComponent } from './pages/motion-view/components/motion-comments/motion-comments.component'; +import { MotionContentComponent } from './pages/motion-view/components/motion-content/motion-content.component'; +import { MotionDetailDiffComponent } from './pages/motion-view/components/motion-detail-diff/motion-detail-diff.component'; +import { MotionDetailDiffSummaryComponent } from './pages/motion-view/components/motion-detail-diff-summary/motion-detail-diff-summary.component'; +import { MotionDetailOriginalChangeRecommendationsComponent } from './pages/motion-view/components/motion-detail-original-change-recommendations/motion-detail-original-change-recommendations.component'; +import { MotionExtensionFieldComponent } from './pages/motion-view/components/motion-extension-field/motion-extension-field.component'; +import { MotionFinalVersionComponent } from './pages/motion-view/components/motion-final-version/motion-final-version.component'; +import { MotionHighlightFormComponent } from './pages/motion-view/components/motion-highlight-form/motion-highlight-form.component'; +import { MotionManageMotionMeetingUsersComponent } from './pages/motion-view/components/motion-manage-motion-meeting-users/motion-manage-motion-meeting-users.component'; +import { MotionManagePollsComponent } from './pages/motion-view/components/motion-manage-polls/motion-manage-polls.component'; +import { MotionManageTimestampComponent } from './pages/motion-view/components/motion-manage-timestamp/motion-manage-timestamp.component'; +import { MotionManageTitleComponent } from './pages/motion-view/components/motion-manage-title/motion-manage-title.component'; +import { MotionMetaDataComponent } from './pages/motion-view/components/motion-meta-data/motion-meta-data.component'; +import { MotionPersonalNoteComponent } from './pages/motion-view/components/motion-personal-note/motion-personal-note.component'; +import { MotionViewComponent } from './pages/motion-view/components/motion-view/motion-view.component'; +import { ParagraphBasedAmendmentComponent } from './pages/motion-view/components/paragraph-based-amendment/paragraph-based-amendment.component'; import { MotionDetailServiceModule } from './services/motion-detail-service.module'; @NgModule({ declarations: [ MotionAddPollButtonComponent, MotionDetailComponent, - MotionDetailViewComponent, - AmendmentCreateWizardComponent, + MotionViewComponent, MotionContentComponent, MotionMetaDataComponent, MotionManageTitleComponent, @@ -82,7 +78,6 @@ import { MotionDetailServiceModule } from './services/motion-detail-service.modu MotionHighlightFormComponent, MotionExtensionFieldComponent, MotionManageMotionMeetingUsersComponent, - MotionParagraphbasedAmendmentComponent, MotionDetailDiffComponent, MotionDetailDiffSummaryComponent, MotionDetailOriginalChangeRecommendationsComponent, @@ -142,10 +137,7 @@ import { MotionDetailServiceModule } from './services/motion-detail-service.modu ScrollingModule, ChipSelectModule, MatBadgeModule, - - // Amendment create wizard - MatStepperModule, - MatRadioModule + MatProgressSpinnerModule ] }) export class MotionDetailModule {} diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/amendment-create-wizard/amendment-create-wizard.component.html b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/amendment-create-wizard/amendment-create-wizard.component.html similarity index 98% rename from client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/amendment-create-wizard/amendment-create-wizard.component.html rename to client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/amendment-create-wizard/amendment-create-wizard.component.html index 7b44c3124a..1e3495d74f 100644 --- a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/amendment-create-wizard/amendment-create-wizard.component.html +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/amendment-create-wizard/amendment-create-wizard.component.html @@ -100,8 +100,7 @@

@if (paragraph.lineFrom >= paragraph.lineTo) { {{ 'Line' | translate }} {{ paragraph.lineFrom }}: - } - @if (paragraph.lineFrom < paragraph.lineTo) { + } @else { {{ 'Line' | translate }} {{ paragraph.lineFrom }} - {{ paragraph.lineTo }}: diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/amendment-create-wizard/amendment-create-wizard.component.scss b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/amendment-create-wizard/amendment-create-wizard.component.scss similarity index 100% rename from client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/amendment-create-wizard/amendment-create-wizard.component.scss rename to client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/amendment-create-wizard/amendment-create-wizard.component.scss diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/amendment-create-wizard/amendment-create-wizard.component.spec.ts b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/amendment-create-wizard/amendment-create-wizard.component.spec.ts similarity index 100% rename from client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/amendment-create-wizard/amendment-create-wizard.component.spec.ts rename to client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/amendment-create-wizard/amendment-create-wizard.component.spec.ts diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/amendment-create-wizard/amendment-create-wizard.component.ts b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/amendment-create-wizard/amendment-create-wizard.component.ts similarity index 96% rename from client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/amendment-create-wizard/amendment-create-wizard.component.ts rename to client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/amendment-create-wizard/amendment-create-wizard.component.ts index b1e2db4d92..7d14f84fff 100644 --- a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/amendment-create-wizard/amendment-create-wizard.component.ts +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/amendment-create-wizard/amendment-create-wizard.component.ts @@ -10,13 +10,13 @@ import { ViewMotion } from 'src/app/site/pages/meetings/pages/motions'; import { OperatorService } from 'src/app/site/services/operator.service'; import { PromptService } from 'src/app/ui/modules/prompt-dialog'; -import { AmendmentParagraphs } from '../../../../../../../../../domain/models/motions/motion'; -import { AmendmentControllerService } from '../../../../services/common/amendment-controller.service'; -import { MotionControllerService } from '../../../../services/common/motion-controller.service/motion-controller.service'; +import { AmendmentParagraphs } from '../../../../../../../../../../../domain/models/motions/motion'; +import { AmendmentControllerService } from '../../../../../../services/common/amendment-controller.service'; +import { MotionControllerService } from '../../../../../../services/common/motion-controller.service/motion-controller.service'; import { MotionLineNumberingService, ParagraphToChoose -} from '../../../../services/common/motion-line-numbering.service/motion-line-numbering.service'; +} from '../../../../../../services/common/motion-line-numbering.service/motion-line-numbering.service'; @Component({ selector: `os-amendment-create-wizard`, 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 new file mode 100644 index 0000000000..810de57fc4 --- /dev/null +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/motion-form/motion-form.component.html @@ -0,0 +1,218 @@ + + @if (hasLoaded | async) { + + +
+ @if (motion && !newMotion && !vp.isMobile) { +

{{ 'Motion' | translate }}

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

{{ 'New motion' | translate }}

+ } @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.scss b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/motion-form/motion-form.component.scss new file mode 100644 index 0000000000..c93979e311 --- /dev/null +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/motion-form/motion-form.component.scss @@ -0,0 +1,23 @@ +span { + margin: 0; +} + +.extra-data { + margin-top: 18px; +} + +.motion-content { + .form-id-title { + display: flex; + + .form-number { + flex: 0 0 95px; + max-width: 95px; + margin-right: 1em; + } + + .form-title { + flex: 1 1 auto; + } + } +} diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/motion-form/motion-form.component.spec.ts b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/motion-form/motion-form.component.spec.ts new file mode 100644 index 0000000000..8c2d79b66e --- /dev/null +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/motion-form/motion-form.component.spec.ts @@ -0,0 +1,24 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MotionFormComponent } from './motion-form.component'; + +xdescribe(`MotionDetailFormComponent`, () => { + let component: MotionFormComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [MotionFormComponent] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(MotionFormComponent); + 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/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 new file mode 100644 index 0000000000..0ef424e721 --- /dev/null +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/components/motion-form/motion-form.component.ts @@ -0,0 +1,590 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + HostListener, + OnInit, + ViewEncapsulation +} from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { ActivatedRoute } from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; +import { + auditTime, + BehaviorSubject, + distinctUntilChanged, + filter, + firstValueFrom, + map, + skip, + startWith, + Subscription, + tap +} 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 { 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 { 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 { 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'; + +/** + * 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; + attachment_mediafile_ids?: any; +}; + +@Component({ + selector: `os-motion-form`, + templateUrl: `./motion-form.component.html`, + styleUrls: [`./motion-form.component.scss`], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None +}) +export class MotionFormComponent extends BaseMeetingComponent implements OnInit { + public readonly collection = ViewMotion.COLLECTION; + + /** + * Determine if the motion is a new (unsent) amendment to another motion + */ + public amendmentEdit = false; + + /** + * Determine if the motion is new + */ + public newMotion = false; + + /** + * Sets the motions, e.g. via an autoupdate. Reload important things here: + * - Reload the recommendation. Not changed with autoupdates, but if the motion is loaded this needs to run. + */ + public set motion(motion: ViewMotion) { + this._motion = motion; + } + + public get motion(): ViewMotion { + return this._motion; + } + + public get canChangeMetadata(): boolean { + return this.perms.isAllowed(`change_metadata`, this.motion); + } + + public get isParagraphBasedAmendment(): boolean { + return this.isExisting && this.motion.isParagraphBasedAmendment(); + } + + public get isExisting(): boolean { + return this.motion instanceof ViewMotion; + } + + public get hasCategories(): boolean { + return this.categoryRepo.getViewModelList().length > 0; + } + + /** + * Constant to identify the notification-message. + */ + public NOTIFICATION_EDIT_MOTION = `notifyEditMotion`; + + public contentForm!: UntypedFormGroup; + + 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); + + public preamble = ``; + public reasonRequired = false; + public minSupporters = 0; + + public temporaryMotion: any = {}; + + public canSave = false; + + public hasLoaded = new BehaviorSubject(false); + + private titleFieldUpdateSubscription: Subscription; + + private _canSaveParagraphBasedAmendment = true; + private _paragraphBasedAmendmentContent: any = {}; + private _motionContent: any = {}; + private _initialState: any = {}; + + private _editSubscriptions: Subscription[] = []; + + private _motionNumbersSubject = new BehaviorSubject([]); + + 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 participantRepo: ParticipantControllerService, + public participantSortService: ParticipantListSortService, + public categoryRepo: MotionCategoryControllerService, + public workflowRepo: MotionWorkflowControllerService, + private fb: UntypedFormBuilder, + private route: ActivatedRoute, + private motionController: MotionControllerService, + private amendmentRepo: AmendmentControllerService, + private perms: MotionPermissionService, + private prompt: PromptService, + private cd: ChangeDetectorRef + ) { + super(); + + 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)) + ); + } + + /** + * Init. + * Sets all required subjects and fills in the required information + */ + public ngOnInit(): void { + this.subscriptions.push( + this.activeMeetingIdService.meetingIdObservable.subscribe(() => { + this.hasLoaded.next(false); + }), + this.vp.isMobileSubject.subscribe(() => { + this.cd.markForCheck(); + }) + ); + } + + /** + * In the ui are no distinct buttons for update or create. This is decided here. + */ + 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(); + } + }; + } + + 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`]); + } + } + + /** + * 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 (Object.keys(this._motionContent).length) { + event.returnValue = false; + } + } + + public async onIdFound(id: Id | null): Promise { + this._motionId = id; + if (id) { + await this.loadMotionById(); + } else { + await this.initNewMotion(); + } + + this.patchForm(); + this.initContentFormSubscription(); + this.propagateChanges(); + this.attachMotionNumbersSubject(); + + this.hasLoaded.next(true); + } + + /** + * 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); + } + + 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`); + } + + /** + * 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); + } + } + + /** + * Async load the values of the motion in the Form. + */ + protected patchForm(): void { + if (!this.contentForm) { + this.contentForm = this.createForm(); + } + + const contentPatch: { [key: string]: any } = {}; + Object.keys(this.contentForm.controls).forEach(ctrl => { + if (this.isExisting || this.motion[ctrl]) { + contentPatch[ctrl] = this.motion[ctrl]; + } + }); + + if (this.contentForm.controls[`attachment_mediafile_ids`]) { + contentPatch[`attachment_mediafile_ids`] = this.motion.attachment_meeting_mediafiles?.map( + file => file.mediafile_id + ); + } + + if (this.isParagraphBasedAmendment) { + this.contentForm.get(`text`)?.clearValidators(); // manually adjust validators + } + + if (this.isExisting) { + this._initialState = deepCopy(contentPatch); + } + this.contentForm.patchValue(contentPatch); + + if (this.amendmentEdit && !this.titleFieldUpdateSubscription) { + const parentId = Number(this.route.snapshot.queryParams[`parent`]); + if (parentId && !Number.isNaN(parentId)) { + this.titleFieldUpdateSubscription = this.motionController + .getViewModelObservable(parentId) + .pipe( + map(parent => { + return { number: parent?.number, text: parent?.text }; + }), + distinctUntilChanged((p, c) => p.text === c.text), + skip(1) + ) + .subscribe(value => { + this.prompt + .open( + this.translate.instant(`Parent motion text changed`), + this.translate.instant( + `Do you want to update the amendment text? Your changes will be lost.` + ) + ) + .then(choice => { + if (choice) { + this.contentForm.patchValue({ text: value.text }); + } + }); + }); + this.subscriptions.push(this.titleFieldUpdateSubscription); + } + } + } + + private async initNewMotion(): Promise { + // new motion + super.setTitle(`New motion`); + this.newMotion = true; + if (this.route.snapshot.queryParams[`parent`]) { + await this.initializeAmendment(); + } else { + this.motion = {} as any; + } + + this.cd.markForCheck(); + } + + 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.cd.markForCheck(); + + this.subscriptions.push( + this.motionController + .getViewModelObservable(motionId) + .pipe( + tap(motion => { + if (this.contentForm) { + for (const ctrl of Object.keys(this.contentForm.controls)) { + if (this.contentForm.get(ctrl).pristine) { + this.contentForm.get(ctrl).setValue(motion[ctrl]); + } + } + } + }), + distinctUntilChanged((_, c) => { + for (const ctrl of Object.keys(this.contentForm.controls)) { + if (JSON.stringify(c[ctrl]) !== JSON.stringify(this.contentForm.get(ctrl).value)) { + return false; + } + } + + return true; + }), + auditTime(2000), + skip(1) + ) + .subscribe(motion => { + if (motion) { + const title = motion.getTitle(); + super.setTitle(title); + this.motion = motion; + this.prompt + .open( + this.translate.instant(`Motion changed`), + this.translate.instant(`Discard changes and update form?`) + ) + .then(choice => { + if (choice) { + this.patchForm(); + } + }); + } + }) + ); + } + + private async updateMotion(newMotionValues: any, motion: ViewMotion): Promise { + try { + await this.motionController.update(newMotionValues, motion).resolve(); + } catch (e) { + this.raiseError(e); + } + } + + private async ensureParentIsAvailable(parentId: Id): Promise { + let motion: ViewMotion = this.motionController.getViewModel(parentId); + if (!motion || motion.text === undefined) { + motion = await firstValueFrom( + this.motionController + .getViewModelObservable(parentId) + .pipe( + filter( + motion => + !!motion?.id && + (motion.text !== undefined || + this.meetingSettingsService.instant(`motions_amendments_text_mode`) !== `fulltext`) + ) + ) + ); + } + + return motion; + } + + private async initializeAmendment(): Promise { + const motion: any = {}; + this._parentId = +this.route.snapshot.queryParams[`parent`] || null; + this.amendmentEdit = true; + const parentMotion = await this.ensureParentIsAvailable(this._parentId!); + motion.lead_motion_id = this._parentId; + if (parentMotion) { + const defaultTitle = `${this.translate.instant(`Amendment to`)} ${parentMotion.numberOrTitle}`; + motion.title = defaultTitle; + motion.category_id = parentMotion.category_id; + motion.workflow_id = +this.meetingSettingsService.instant(`motions_default_amendment_workflow_id`); + const amendmentTextMode = this.meetingSettingsService.instant(`motions_amendments_text_mode`); + if (amendmentTextMode === `fulltext`) { + motion.text = parentMotion.text; + } + this.motion = motion; + } else { + this.motion = {} as any; + } + } + + private initContentFormSubscription(): void { + for (const subscription of this._editSubscriptions) { + subscription.unsubscribe(); + } + this._editSubscriptions = []; + for (const controlName of Object.keys(this.contentForm.controls)) { + const subscription = this.contentForm + .get(controlName)! + .valueChanges.pipe(startWith(this.contentForm.get(controlName).getRawValue())) + .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( + 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) + ); + } + + private showMotionEditConflictWarningIfNecessary(): void { + if (this.motion.amendments?.filter(amend => amend.isParagraphBasedAmendment()).length > 0) { + const msg = this.translate.instant( + `Warning: Amendments exist for this motion. Editing this text will likely impact them negatively. Particularily, amendments might become unusable if the paragraph they affect is deleted.` + ); + this.raiseWarning(msg); + } + } + + 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 createNewUser(username: string): Promise { + return this.participantRepo.createFromString(username); + } + + /** + * 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_mediafile_ids: [[]], + agenda_parent_id: [], + submitter_ids: [[]], + supporter_ids: [[]], + workflow_id: [ + +this.meetingSettingsService.instant( + this.amendmentEdit ? `motions_default_amendment_workflow_id` : `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/components/paragraph-based-amendment/paragraph-based-amendment.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 similarity index 100% rename from client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/paragraph-based-amendment/paragraph-based-amendment.component.scss rename to 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 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/components/paragraph-based-amendment/paragraph-based-amendment.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 similarity index 50% rename from client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/paragraph-based-amendment/paragraph-based-amendment.component.ts rename to 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 index 779e099156..fee6fc18be 100644 --- a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/paragraph-based-amendment/paragraph-based-amendment.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 @@ -1,23 +1,9 @@ -import { - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - ElementRef, - EventEmitter, - Input, - Output -} from '@angular/core'; +import { ChangeDetectionStrategy, Component, EventEmitter, Output } from '@angular/core'; import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { TranslateService } from '@ngx-translate/core'; -import { UnsafeHtml } from 'src/app/domain/definitions/key-types'; -import { ChangeRecoMode, LineNumberingMode } from 'src/app/domain/models/motions/motions.constants'; -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 { DiffLinesInParagraph } from '../../../../definitions/index'; -import { ParagraphToChoose } from '../../../../services/common/motion-line-numbering.service/motion-line-numbering.service'; -import { ViewMotion } from '../../../../view-models'; -import { BaseMotionDetailChildComponent } from '../../base/base-motion-detail-child.component'; +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 }; @@ -28,103 +14,32 @@ interface ParagraphBasedAmendmentContent { const CONTENT_FORM_SUBSCRIPTION_NAME = `contentForm`; @Component({ - selector: `os-paragraph-based-amendment`, - templateUrl: `./paragraph-based-amendment.component.html`, - styleUrls: [`./paragraph-based-amendment.component.scss`], + 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 ParagraphBasedAmendmentComponent extends BaseMotionDetailChildComponent { - public readonly LineNumberingMode = LineNumberingMode; - public readonly ChangeRecoMode = ChangeRecoMode; - - @Input() - public changesForDiffMode: ViewUnifiedChange[] = []; - - @Input() - public highlightedLine!: number; - - @Input() - public isFinalEdit = false; - - @Output() - public createChangeRecommendation = new EventEmitter(); - +export class ParagraphBasedAmendmentEditorComponent extends BaseMotionDetailChildComponent { @Output() public formChanged = new EventEmitter(); @Output() public validStateChanged = new EventEmitter(); - public showAmendmentContext = false; - public selectedParagraphs: ParagraphToChoose[] = []; public brokenParagraphs: string[] = []; public contentForm: UntypedFormGroup | null = null; - public amendmentErrorMessage: string | null = null; - - public get amendmentLines(): DiffLinesInParagraph[] | null { - return this.motion?.changedAmendmentLines; - } - - public get nativeElement(): any { - return this.el.nativeElement; - } - public constructor( protected override translate: TranslateService, - private fb: UntypedFormBuilder, - private cd: ChangeDetectorRef, - private el: ElementRef + private fb: UntypedFormBuilder ) { super(); } - /** - * This returns the plain HTML of a changed area in an amendment, including its context, - * for the purpose of piping it into . - * This component works with plain HTML, hence we are composing plain HTML here, too. - * - * @param {DiffLinesInParagraph} paragraph - * @returns {string} - * - * TODO: Seems to be directly duplicated in the slide - */ - public getAmendmentDiffTextWithContext(paragraph: DiffLinesInParagraph): UnsafeHtml { - return ( - `
${paragraph.textPre}
` + - `
${paragraph.text}
` + - `
${paragraph.textPost}
` - ); - } - - /** - * If `this.motion` is an amendment, this returns the list of all changed paragraphs. - * - * @returns {DiffLinesInParagraph[]} - */ - public getAmendmentParagraphs(): DiffLinesInParagraph[] { - try { - this.amendmentErrorMessage = null; - return this.motion?.getAmendmentParagraphLines(this.changeRecoMode, this.showAmendmentContext) || []; - } catch (e: any) { - this.amendmentErrorMessage = e.toString(); - return []; - } - } - - public getAmendmentParagraphLinesTitle(paragraph: DiffLinesInParagraph): string { - return this.motion?.getParagraphTitleByParagraph(paragraph) || ``; - } - - public isControlInvalid(paragraphNumber: number): boolean { - const control = this.contentForm!.get(paragraphNumber.toString())!; - return control.invalid && (control.dirty || control.touched); - } - - protected override onEnterEditMode(): void { + protected override onAfterSetMotion(): void { if (this.contentForm) { this.contentForm = null; } @@ -135,10 +50,9 @@ export class ParagraphBasedAmendmentComponent extends BaseMotionDetailChildCompo this.propagateChanges(); } - protected override onAfterSetMotion(previous: ViewMotion): void { - if (!previous) { - this.onEnterEditMode(); - } + public isControlInvalid(paragraphNumber: number): boolean { + const control = this.contentForm!.get(paragraphNumber.toString())!; + return control.invalid && (control.dirty || control.touched); } private createForm(): ParagraphBasedAmendmentContent { diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/motion-form-routing.module.ts b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/motion-form-routing.module.ts new file mode 100644 index 0000000000..69b7c26d6c --- /dev/null +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/motion-form-routing.module.ts @@ -0,0 +1,17 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +import { AmendmentCreateWizardComponent } from './components/amendment-create-wizard/amendment-create-wizard.component'; +import { MotionFormComponent } from './components/motion-form/motion-form.component'; + +const routes: Routes = [ + { path: `:id/create-amendment`, component: AmendmentCreateWizardComponent }, + { path: `:id/edit`, component: MotionFormComponent }, + { path: `new`, component: MotionFormComponent } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class MotionFormRoutingModule {} 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 new file mode 100644 index 0000000000..95074ce244 --- /dev/null +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-form/motion-form.module.ts @@ -0,0 +1,57 @@ +import { ScrollingModule } from '@angular/cdk/scrolling'; +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { MatCardModule } from '@angular/material/card'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatRadioModule } from '@angular/material/radio'; +import { MatStepperModule } from '@angular/material/stepper'; +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 { MotionDetailDirectivesModule } from '../../modules/directives/motion-detail-directives.module'; +import { AmendmentCreateWizardComponent } from './components/amendment-create-wizard/amendment-create-wizard.component'; +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, ParagraphBasedAmendmentEditorComponent, AmendmentCreateWizardComponent], + imports: [ + CommonModule, + MotionFormRoutingModule, + DirectivesModule, + PipesModule, + MotionDetailDirectivesModule, + AgendaContentObjectFormModule, + HeadBarModule, + DetailViewModule, + AttachmentControlModule, + EditorModule, + SearchSelectorModule, + MatCardModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + FormsModule, + ReactiveFormsModule, + OpenSlidesTranslationModule.forChild(), + + // Amendment create wizard + ScrollingModule, + MatStepperModule, + MatRadioModule, + MatCheckboxModule + ] +}) +export class MotionFormModule {} diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/base/base-motion-detail-action-card.component.ts b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/base/base-motion-detail-action-card.component.ts similarity index 99% rename from client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/base/base-motion-detail-action-card.component.ts rename to client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/base/base-motion-detail-action-card.component.ts index cb453c7f25..ab2fc8fbfe 100644 --- a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/base/base-motion-detail-action-card.component.ts +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/base/base-motion-detail-action-card.component.ts @@ -35,6 +35,7 @@ export abstract class BaseMotionDetailActionCardComponent extends BaseComponent protected override translate = inject(TranslateService); protected cd = inject(ChangeDetectorRef); protected fb = inject(FormBuilder); + public constructor() { super(); this.formGroup = this.fb.group({ text: `` }); diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-add-poll-button/motion-add-poll-button.component.html b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-add-poll-button/motion-add-poll-button.component.html similarity index 94% rename from client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-add-poll-button/motion-add-poll-button.component.html rename to client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-add-poll-button/motion-add-poll-button.component.html index 6e8756fdd6..4588f38ce1 100644 --- a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-add-poll-button/motion-add-poll-button.component.html +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-add-poll-button/motion-add-poll-button.component.html @@ -1,6 +1,6 @@
- } - @if (!isEditing && hasComment()) { - - } @if (isEditing) { - } - @if (isEditing) { + + } @else { + @if (canBeEdited || hasSubmitterEditRights) { + + } + @if (hasComment()) { + + } } diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-comment/motion-comment.component.scss b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-comment/motion-comment.component.scss similarity index 100% rename from client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-comment/motion-comment.component.scss rename to client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-comment/motion-comment.component.scss diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-comment/motion-comment.component.spec.ts b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-comment/motion-comment.component.spec.ts similarity index 100% rename from client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-comment/motion-comment.component.spec.ts rename to client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-comment/motion-comment.component.spec.ts diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-comment/motion-comment.component.ts b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-comment/motion-comment.component.ts similarity index 92% rename from client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-comment/motion-comment.component.ts rename to client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-comment/motion-comment.component.ts index a8308af8c6..ca27237ed1 100644 --- a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-comment/motion-comment.component.ts +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-comment/motion-comment.component.ts @@ -5,8 +5,8 @@ import { MotionComment } from 'src/app/domain/models/motions/motion-comment'; import { ViewMotionComment, ViewMotionCommentSection } from 'src/app/site/pages/meetings/pages/motions'; import { OperatorService } from 'src/app/site/services/operator.service'; -import { MotionCommentControllerService } from '../../../../modules/comments/services/motion-comment-controller.service'; -import { MotionPdfExportService } from '../../../../services/export/motion-pdf-export.service/motion-pdf-export.service'; +import { MotionCommentControllerService } from '../../../../../../modules/comments/services/motion-comment-controller.service'; +import { MotionPdfExportService } from '../../../../../../services/export/motion-pdf-export.service/motion-pdf-export.service'; import { BaseMotionDetailActionCardComponent } from '../../base/base-motion-detail-action-card.component'; @Component({ diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-comments/motion-comments.component.html b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-comments/motion-comments.component.html new file mode 100644 index 0000000000..6967e050b0 --- /dev/null +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-comments/motion-comments.component.html @@ -0,0 +1,11 @@ +@for (section of sections$ | async; track section.id; let index = $index) { + @if (canReadSection(section)) { + + } +} diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-comments/motion-comments.component.scss b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-comments/motion-comments.component.scss similarity index 100% rename from client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-comments/motion-comments.component.scss rename to client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-comments/motion-comments.component.scss diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-comments/motion-comments.component.spec.ts b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-comments/motion-comments.component.spec.ts similarity index 100% rename from client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-comments/motion-comments.component.spec.ts rename to client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-comments/motion-comments.component.spec.ts diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-comments/motion-comments.component.ts b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-comments/motion-comments.component.ts similarity index 60% rename from client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-comments/motion-comments.component.ts rename to client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-comments/motion-comments.component.ts index 91284b4601..e90059c9b3 100644 --- a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-comments/motion-comments.component.ts +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-comments/motion-comments.component.ts @@ -1,9 +1,10 @@ -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { Observable } from 'rxjs'; import { ViewMotion, ViewMotionComment, ViewMotionCommentSection } from 'src/app/site/pages/meetings/pages/motions'; import { OperatorService } from 'src/app/site/services/operator.service'; import { BaseUiComponent } from 'src/app/ui/base/base-ui-component'; -import { MotionCommentSectionControllerService } from '../../../../modules/comments/services/motion-comment-section-controller.service'; +import { MotionCommentSectionControllerService } from '../../../../../../modules/comments/services/motion-comment-section-controller.service'; @Component({ selector: `os-motion-comments`, @@ -11,11 +12,11 @@ import { MotionCommentSectionControllerService } from '../../../../modules/comme styleUrls: [`./motion-comments.component.scss`], changeDetection: ChangeDetectionStrategy.OnPush }) -export class MotionCommentsComponent extends BaseUiComponent implements OnInit { +export class MotionCommentsComponent extends BaseUiComponent { /** * An array of all sections the operator can see. */ - public sections: ViewMotionCommentSection[] = []; + public sections$: Observable = this.commentSectionRepo.getViewModelListObservable(); /** * The motion, which these comments belong to. @@ -25,39 +26,16 @@ export class MotionCommentsComponent extends BaseUiComponent implements OnInit { public constructor( private commentSectionRepo: MotionCommentSectionControllerService, - private operator: OperatorService, - private cd: ChangeDetectorRef + private operator: OperatorService ) { super(); } - public ngOnInit(): void { - this.subscriptions.push( - this.commentSectionRepo.getViewModelListObservable().subscribe(sections => { - if (sections && sections.length) { - this.sections = sections; - this.filterSections(); - this.cd.detectChanges(); - } - }) - ); - } - public getCommentForSection(section: ViewMotionCommentSection): ViewMotionComment { return this.motion.getCommentForSection(section)!; } - /** - * sets the `sections` member with sections, if the operator has reading permissions. - */ - private filterSections(): void { - if (this.sections?.length) { - this.sections = this.sections.filter(section => this.canReadSection(section)); - this.cd.markForCheck(); - } - } - - private canReadSection(section: ViewMotionCommentSection): boolean { + public canReadSection(section: ViewMotionCommentSection): boolean { return ( this.operator.isInGroupIds(...(section.read_group_ids || []), ...(section.write_group_ids || [])) || (section.submitter_can_write && diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-content/motion-content.component.html b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-content/motion-content.component.html new file mode 100644 index 0000000000..39c4a0bc59 --- /dev/null +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-content/motion-content.component.html @@ -0,0 +1,85 @@ + +@if (!isParagraphBasedAmendment) { + @if (changeRecoMode !== ChangeRecoMode.Diff) { + + @if (showPreamble) { + + {{ preamble$ | async }} + + } + + + } @else { + + } +} @else { + + +} + +
+ + @if (motion.reason) { +
+

{{ 'Reason' | translate }}

+ +
+ } + +
+ + @if (hasAttachments) { +
+
+

+ {{ 'Attachments' | translate }} + attach_file +

+ + @for (file of motion.attachment_meeting_mediafiles$ | async; track file.id) { + + {{ file.title }} + + } + +
+
+ } +
+
diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-content/motion-content.component.scss b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-content/motion-content.component.scss similarity index 100% rename from client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-content/motion-content.component.scss rename to client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-content/motion-content.component.scss diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-content/motion-content.component.spec.ts b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-content/motion-content.component.spec.ts similarity index 100% rename from client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-content/motion-content.component.spec.ts rename to client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-content/motion-content.component.spec.ts diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-content/motion-content.component.ts b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-content/motion-content.component.ts new file mode 100644 index 0000000000..6de7981e1d --- /dev/null +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-content/motion-content.component.ts @@ -0,0 +1,167 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { BehaviorSubject, combineLatest, map, Observable } from 'rxjs'; +import { ChangeRecoMode, LineNumberingMode } from 'src/app/domain/models/motions/motions.constants'; +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 { 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'; + +@Component({ + selector: `os-motion-content`, + templateUrl: `./motion-content.component.html`, + styleUrls: [`./motion-content.component.scss`], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class MotionContentComponent extends BaseMotionDetailChildComponent { + public readonly ChangeRecoMode = ChangeRecoMode; + public readonly LineNumberingMode = LineNumberingMode; + + @Input() + public changeRecoMode: ChangeRecoMode; + + @Input() + public lineNumberingMode: LineNumberingMode; + + private unifiedChanges$: Observable & { value: ViewUnifiedChange[] }; + + @Input() + public set unifiedChanges( + value: ViewUnifiedChange[] | (Observable & { value: ViewUnifiedChange[] }) + ) { + if (value !== this.unifiedChanges$) { + if (value instanceof Observable) { + this.unifiedChanges$ = value; + } else { + this.unifiedChanges$ = new BehaviorSubject(value); + } + + this.updateObservables(); + } + } + + public get unifiedChanges(): ViewUnifiedChange[] | Observable { + return this.unifiedChanges$.value; + } + + @Output() + public updateCrMode = new EventEmitter(); + + public scrollToChange: ViewUnifiedChange | null = null; + + public changesForDiffMode$: Observable = null; + public formattedTextPlain$: Observable = null; + + public preamble$ = this.meetingSettingsService.get(`motions_preamble`); + + public get showPreamble(): boolean { + return this.motion.showPreamble; + } + + public get canChangeMetadata(): boolean { + return this.perms.isAllowed(`change_metadata`, this.motion); + } + + public get isParagraphBasedAmendment(): boolean { + return this.motion.isParagraphBasedAmendment(); + } + + public get hasAttachments(): boolean { + return this.motion.hasAttachments(); + } + + /** + * Indicates the currently highlighted line, if any. + */ + public highlightedLine!: number; + + public constructor( + protected override translate: TranslateService, + private dialog: MotionChangeRecommendationDialogService, + private perms: MotionPermissionService + ) { + super(); + } + + /** + * 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); + } + + /** + * In the original version, a change-recommendation-annotation has been clicked + * -> Go to the diff view and scroll to the change recommendation + */ + public gotoChangeRecommendation(changeRecommendation: ViewUnifiedChange): void { + this.scrollToChange = changeRecommendation; + this.updateCrMode.emit(ChangeRecoMode.Diff); + } + + private updateObservables(): void { + this.formattedTextPlain$ = combineLatest([ + this.meetingSettingsService.get(`motions_line_length`), + this.unifiedChanges$ + ]).pipe( + map(([lineLength, changes]) => { + if (lineLength) { + return this.motionFormatService.formatMotion({ + targetMotion: this.motion, + crMode: this.changeRecoMode, + changes: this.changeRecoMode === ChangeRecoMode.Original ? [] : changes, + lineLength: this.lineLength, + highlightedLine: this.highlightedLine, + firstLine: this.motion.firstLine + }); + } + + return this.motion.text; + }) + ); + + this.changesForDiffMode$ = combineLatest([this.showAllAmendments$, this.unifiedChanges$]).pipe( + map(([_, changes]) => + changes.filter(change => { + if (this.showAllAmendments) { + return true; + } else { + return change.showInDiffView(); + } + }) + ) + ); + } +} diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-detail-diff-summary/motion-detail-diff-summary.component.html b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-detail-diff-summary/motion-detail-diff-summary.component.html similarity index 85% rename from client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-detail-diff-summary/motion-detail-diff-summary.component.html rename to client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-detail-diff-summary/motion-detail-diff-summary.component.html index 2e09d305e5..13bd7387a9 100644 --- a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-detail-diff-summary/motion-detail-diff-summary.component.html +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-detail-diff-summary/motion-detail-diff-summary.component.html @@ -16,15 +16,13 @@ @if (!change.isTitleChange()) { {{ 'Line' | translate }} {{ formatLineRange(change) }} - } - @if (change.isTitleChange()) { + } @else { {{ 'Title' | translate }} } - @if (isChangeRecommendation(change)) { -  ({{ 'Change recommendation' | translate }}) - } @if (isAmendment(change)) {  ({{ 'Amendment' | translate }} {{ change.getIdentifier() }}) + } @else { +  ({{ 'Change recommendation' | translate }}) } @if (isChangeRecommendation(change)) { @@ -39,8 +37,7 @@ @if (change.isRejected() && !isAmendment(change)) { – {{ 'Rejected' | translate }} - } - @if ((change.isAccepted() || isAmendment(change)) && change.stateName) { + } @else if (change.stateName) { – {{ change.stateName }} } @@ -59,8 +56,5 @@ - @if (changes.length === 0) { -
{{ 'No change recommendations yet' | translate }}
- }
} diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-detail-diff-summary/motion-detail-diff-summary.component.scss b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-detail-diff-summary/motion-detail-diff-summary.component.scss similarity index 100% rename from client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-detail-diff-summary/motion-detail-diff-summary.component.scss rename to client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-detail-diff-summary/motion-detail-diff-summary.component.scss diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-detail-diff-summary/motion-detail-diff-summary.component.spec.ts b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-detail-diff-summary/motion-detail-diff-summary.component.spec.ts similarity index 100% rename from client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-detail-diff-summary/motion-detail-diff-summary.component.spec.ts rename to client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-detail-diff-summary/motion-detail-diff-summary.component.spec.ts diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-detail-diff-summary/motion-detail-diff-summary.component.ts b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-detail-diff-summary/motion-detail-diff-summary.component.ts similarity index 84% rename from client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-detail-diff-summary/motion-detail-diff-summary.component.ts rename to client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-detail-diff-summary/motion-detail-diff-summary.component.ts index cf0cbebd41..064fa1e5d9 100644 --- a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-detail-diff-summary/motion-detail-diff-summary.component.ts +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-detail-diff-summary/motion-detail-diff-summary.component.ts @@ -1,11 +1,11 @@ -import { AfterViewInit, Component, Input, ViewEncapsulation } from '@angular/core'; +import { AfterViewInit, ChangeDetectionStrategy, Component, Input, ViewEncapsulation } from '@angular/core'; import { BaseMeetingComponent } from 'src/app/site/pages/meetings/base/base-meeting.component'; import { ViewUnifiedChange } from 'src/app/site/pages/meetings/pages/motions/modules/change-recommendations/view-models/view-unified-change'; -import { getRecommendationTypeName } from '../../../../definitions/recommendation-type-names'; -import { ViewUnifiedChangeType } from '../../../../modules/change-recommendations/definitions/index'; -import { ViewMotion } from '../../../../view-models'; -import { ViewMotionAmendedParagraph } from '../../../../view-models/view-motion-amended-paragraph'; +import { getRecommendationTypeName } from '../../../../../../definitions/recommendation-type-names'; +import { ViewUnifiedChangeType } from '../../../../../../modules/change-recommendations/definitions/index'; +import { ViewMotion } from '../../../../../../view-models'; +import { ViewMotionAmendedParagraph } from '../../../../../../view-models/view-motion-amended-paragraph'; /** * This component displays a summary of the given change requests. @@ -24,7 +24,8 @@ import { ViewMotionAmendedParagraph } from '../../../../view-models/view-motion- selector: `os-motion-detail-diff-summary`, templateUrl: `./motion-detail-diff-summary.component.html`, styleUrls: [`./motion-detail-diff-summary.component.scss`], - encapsulation: ViewEncapsulation.None + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush }) export class MotionDetailDiffSummaryComponent extends BaseMeetingComponent implements AfterViewInit { /** diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-detail-diff/motion-detail-diff.component.html b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-detail-diff/motion-detail-diff.component.html similarity index 96% rename from client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-detail-diff/motion-detail-diff.component.html rename to client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-detail-diff/motion-detail-diff.component.html index a983e215d6..3bccea0e15 100644 --- a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-detail-diff/motion-detail-diff.component.html +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-detail-diff/motion-detail-diff.component.html @@ -47,7 +47,7 @@ } - @for (change of getAllTextChangingObjects(); track change; let i = $index) { + @for (change of allTextChangingObjects; track change.getChangeId(); let i = $index) {
- @if (hasCollissions(change, getAllTextChangingObjects())) { + @if (hasCollissions(change, allTextChangingObjects)) {
warning @@ -157,7 +157,7 @@ @if (change.amendment.state.next_states.length > 0) { } - @for (state of change.amendment.state.next_states; track state) { + @for (state of change.amendment.state.next_states; track state.id) { @@ -42,7 +39,7 @@ @if (!isMobile) {   {{ 'Line numbering' | translate }} - @if (lnMode === LineNumberingMode.None) { + @if (lineNumberingMode === LineNumberingMode.None) {  ( {{ 'none' | translate }} @@ -56,7 +53,7 @@ rate_review @if (!isMobile) {   - {{ verboseChangeRecoMode[crMode] | translate }} + {{ verboseChangeRecoMode[changeRecoMode] | translate }} } } @@ -85,9 +82,8 @@ > edit - } - - @if (isEditingFinalVersion) { + } @else { + - } - - @if (isEditingFinalVersion) { + + - } - - @if (!isEditingFinalVersion) { + + - } - @if (hasChangingObjects) { - } - @if (!isParagraphBasedAmendment && hasChangingObjects) { - + @if (!isParagraphBasedAmendment) { + + } } @if (motion && motion.modified_final_version && !motion.isParagraphBasedAmendment()) { - } - @if (isEditMode && perms.isAllowed('change_metadata')) { - + @if (perms.isAllowed('change_metadata')) { + @if (isEditMode) { + + } @else { + + } }

@if (!isEditMode || !perms.isAllowed('change_metadata')) {
- @for (model of intermediateModels; track model) { + @for (model of intermediateModels$ | async; track model.id) { {{ model.user?.getTitle() }} @@ -25,8 +26,7 @@

}

-} -@if (isEditMode && perms.isAllowed('change_metadata')) { +} @else {
@@ -62,20 +62,20 @@

- } - @if (useAdditionalInput && loadSecondSelectorValues) { -
- - {{ secondSelectorLabel }} - - -
+ @if (loadSecondSelectorValues) { +
+ + {{ secondSelectorLabel }} + + +
+ } }

- } - @if (isEditMode && perms.isAllowed('change_metadata')) { - + @if (perms.isAllowed('change_metadata')) { + @if (isEditMode) { + + } @else { + + } } @@ -16,8 +17,7 @@

{{ motion[field] | localizedDate }}
-} -@if (isEditMode && perms.isAllowed('change_metadata')) { +} @else {
diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-manage-timestamp/motion-manage-timestamp.component.scss b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-manage-timestamp/motion-manage-timestamp.component.scss similarity index 100% rename from client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-manage-timestamp/motion-manage-timestamp.component.scss rename to client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-manage-timestamp/motion-manage-timestamp.component.scss diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-manage-timestamp/motion-manage-timestamp.component.spec.ts b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-manage-timestamp/motion-manage-timestamp.component.spec.ts similarity index 100% rename from client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-manage-timestamp/motion-manage-timestamp.component.spec.ts rename to client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-manage-timestamp/motion-manage-timestamp.component.spec.ts diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-manage-timestamp/motion-manage-timestamp.component.ts b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-manage-timestamp/motion-manage-timestamp.component.ts similarity index 77% rename from client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-manage-timestamp/motion-manage-timestamp.component.ts rename to client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-manage-timestamp/motion-manage-timestamp.component.ts index 228a82150c..bbc7c9d0a9 100644 --- a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-manage-timestamp/motion-manage-timestamp.component.ts +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-manage-timestamp/motion-manage-timestamp.component.ts @@ -4,9 +4,9 @@ import { fromUnixTime, getHours, getMinutes, isDate } from 'date-fns'; import { KeyOfType } from 'src/app/infrastructure/utils/keyof-type'; import { BaseUiComponent } from 'src/app/ui/base/base-ui-component'; -import { MotionControllerService } from '../../../../services/common/motion-controller.service'; -import { MotionPermissionService } from '../../../../services/common/motion-permission.service'; -import { ViewMotion } from '../../../../view-models'; +import { MotionControllerService } from '../../../../../../services/common/motion-controller.service'; +import { MotionPermissionService } from '../../../../../../services/common/motion-permission.service'; +import { ViewMotion } from '../../../../../../view-models'; @Component({ selector: `os-motion-manage-timestamp`, @@ -59,16 +59,18 @@ export class MotionManageTimestampComponent extends BaseUiComponent { time: [``] }); - this.form.get(`date`).valueChanges.subscribe(currDate => { - if (isDate(currDate) !== !!this.form.get(`time`).value) { - this.form.get(`time`).setValue(isDate(currDate) ? `00:00` : ``); - } - }); - this.form.get(`time`).valueChanges.subscribe(currTime => { - if (!!currTime !== isDate(this.form.get(`date`).value)) { - this.form.get(`date`).setValue(!!currTime ? new Date() : null); - } - }); + this.subscriptions.push( + this.form.get(`date`).valueChanges.subscribe(currDate => { + if (isDate(currDate) !== !!this.form.get(`time`).value) { + this.form.get(`time`).setValue(isDate(currDate) ? `00:00` : ``); + } + }), + this.form.get(`time`).valueChanges.subscribe(currTime => { + if (!!currTime !== isDate(this.form.get(`date`).value)) { + this.form.get(`date`).setValue(!!currTime ? new Date() : null); + } + }) + ); } public async onSave(): Promise { diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-manage-title/motion-manage-title.component.html b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-manage-title/motion-manage-title.component.html similarity index 77% rename from client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-manage-title/motion-manage-title.component.html rename to client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-manage-title/motion-manage-title.component.html index 103dea9fa4..ee6d55df7e 100644 --- a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-manage-title/motion-manage-title.component.html +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-manage-title/motion-manage-title.component.html @@ -7,8 +7,8 @@ } } + @let isFavorite = isFavorite$ | async; + @let showSequentialNumber = showSequentialNumber$ | async; @if (showSequentialNumber) { {{ 'Sequential number' | translate }}  {{ motion.sequential_number }} } - @if (showSequentialNumber && parent) { - ·  - } - @if (parent) { + @if (motion.lead_motion$ | async; as parent) { + @if (showSequentialNumber) { + ·  + } {{ 'Amendment to' | translate }} diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-manage-title/motion-manage-title.component.scss b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-manage-title/motion-manage-title.component.scss new file mode 100644 index 0000000000..9d6b5f31ed --- /dev/null +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-manage-title/motion-manage-title.component.scss @@ -0,0 +1,41 @@ +.motion-title { + position: relative; + z-index: 1; + + // Grab the left padding of the parent element to catch hover-events for the :before element + margin-left: -20px; + padding-left: 20px; + + .change-title { + position: relative; + width: 0; + height: 0; + } + + .change-title:before { + position: absolute; + top: 18px; + left: -17px; + display: none; + cursor: pointer; + content: ''; + width: 16px; + height: 16px; + background: url('data:image/svg+xml;utf8,'); + background-size: 16px 16px; + } + + &:hover .change-title:before { + display: block; + } + + .title-change-indicator { + background-color: #0333ff; + position: absolute; + width: 4px; + height: 32px; + left: 10px; + top: 5px; + cursor: pointer; + } +} diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-manage-title/motion-manage-title.component.spec.ts b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-manage-title/motion-manage-title.component.spec.ts similarity index 100% rename from client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-manage-title/motion-manage-title.component.spec.ts rename to client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-manage-title/motion-manage-title.component.spec.ts diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-manage-title/motion-manage-title.component.ts b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-manage-title/motion-manage-title.component.ts similarity index 51% rename from client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-manage-title/motion-manage-title.component.ts rename to client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-manage-title/motion-manage-title.component.ts index 3a750da10f..ab5cbe55cb 100644 --- a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-manage-title/motion-manage-title.component.ts +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-manage-title/motion-manage-title.component.ts @@ -1,36 +1,51 @@ -import { Component, ViewChild } from '@angular/core'; +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, ViewChild } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; -import { distinctUntilChanged, Subscription } from 'rxjs'; +import { map, Observable, Subscription } from 'rxjs'; import { ChangeRecoMode } from 'src/app/domain/models/motions/motions.constants'; -import { PersonalNote } from 'src/app/domain/models/motions/personal-note'; import { ProjectableTitleComponent } from 'src/app/site/pages/meetings/modules/meetings-component-collector/detail-view/components/projectable-title/projectable-title.component'; -import { ViewMotion, ViewMotionChangeRecommendation } from 'src/app/site/pages/meetings/pages/motions'; +import { ViewMotionChangeRecommendation } from 'src/app/site/pages/meetings/pages/motions'; -import { PersonalNoteControllerService } from '../../../../modules/personal-notes/services/personal-note-controller.service/personal-note-controller.service'; -import { BaseMotionDetailChildComponent } from '../../base/base-motion-detail-child.component'; -import { MotionChangeRecommendationDialogService } from '../../modules/motion-change-recommendation-dialog/services/motion-change-recommendation-dialog.service'; +import { PersonalNoteControllerService } from '../../../../../../modules/personal-notes/services/personal-note-controller.service/personal-note-controller.service'; +import { BaseMotionDetailChildComponent } from '../../../../base/base-motion-detail-child.component'; +import { MotionChangeRecommendationDialogService } from '../../../../modules/motion-change-recommendation-dialog/services/motion-change-recommendation-dialog.service'; @Component({ selector: `os-motion-manage-title`, templateUrl: `./motion-manage-title.component.html`, - styleUrls: [`./motion-manage-title.component.scss`] + styleUrls: [`./motion-manage-title.component.scss`], + changeDetection: ChangeDetectionStrategy.OnPush }) export class MotionManageTitleComponent extends BaseMotionDetailChildComponent { @ViewChild(ProjectableTitleComponent) protected readonly titleComponent: ProjectableTitleComponent | undefined; - public titleChangeRecommendation: ViewMotionChangeRecommendation | null = null; + @Input() + public set changeRecoMode(value: ChangeRecoMode) { + this._changeRecoMode = value; + if (this.titleComponent) { + this.titleComponent.update(); + } + } - public override get parent(): ViewMotion | null { - return this.motion.lead_motion!; + public get changeRecoMode(): ChangeRecoMode { + return this._changeRecoMode; } + @Output() + public updateCrMode = new EventEmitter(); + + private _changeRecoMode: ChangeRecoMode; + + public titleChangeRecommendation: ViewMotionChangeRecommendation | null = null; + public getTitleFn = (): string => this.getTitleWithChanges(); - private get personalNote(): PersonalNote | null { - return this.motion.getPersonalNote(); + public get isFavorite$(): Observable { + return this.motion.personal_notes$.pipe(map(notes => (notes?.length ? notes[0]?.star : false))); } + public showSequentialNumber$ = this.meetingSettingsService.get(`motions_show_sequential_number`); + public constructor( protected override translate: TranslateService, private personalNoteRepo: PersonalNoteControllerService, @@ -58,34 +73,34 @@ export class MotionManageTitleComponent extends BaseMotionDetailChildComponent { } public getTitleWithChanges(): string { - return this.changeRecoRepo.getTitleWithChanges( + const title = this.changeRecoRepo.getTitleWithChanges( this.motion.title, this.titleChangeRecommendation!, this.changeRecoMode ); + + if ([`I like trains`, `Ich mag Züge`].indexOf(title) !== -1 && document.querySelector(`.global-headbar`)) { + try { + document.querySelector(`.global-headbar`).classList.add(`train`); + } catch (e) {} + } + + return title; } - /** - * Toggles the favorite status - */ - public toggleFavorite(): void { - this.personalNoteRepo.setPersonalNote({ star: !this.isFavorite() }, this.motion); + public setFavorite(value: boolean): void { + this.personalNoteRepo.setPersonalNote({ star: value }, this.motion); } - public isFavorite(): boolean { - return this.personalNote?.star || false; + public emitDiffModeSwitch(): void { + this.updateCrMode.emit(ChangeRecoMode.Diff); } protected override getSubscriptions(): Subscription[] { return [ this.changeRecoRepo .getTitleChangeRecoOfMotionObservable(this.motion?.id) - ?.subscribe(changeReco => (this.titleChangeRecommendation = changeReco)), - this.viewService.changeRecommendationModeSubject.pipe(distinctUntilChanged()).subscribe(() => { - if (this.titleComponent) { - this.titleComponent.update(); - } - }) + ?.subscribe(changeReco => (this.titleChangeRecommendation = changeReco)) ]; } } diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-meta-data/motion-meta-data.component.html b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-meta-data/motion-meta-data.component.html similarity index 67% rename from client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-meta-data/motion-meta-data.component.html rename to client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-meta-data/motion-meta-data.component.html index a72f32fa76..b0c092c37a 100644 --- a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-meta-data/motion-meta-data.component.html +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-meta-data/motion-meta-data.component.html @@ -19,37 +19,37 @@ } -@if (minSupporters) { +@if (minSupporters$ | async) {
@if (perms.isAllowed('support', motion) || motion.hasSupporters()) {

{{ 'Supporters' | translate }}

- } - - @if (perms.isAllowed('support', motion)) { - - } - - @if (perms.isAllowed('unsupport', motion)) { - + + @if (perms.isAllowed('support', motion)) { + + } + + @if (perms.isAllowed('unsupport', motion)) { + + } } @if (motion.hasSupporters()) { - } - @if (showSupporters) { -

- - {{ supporter.full_name }} - -

+ @if (showSupporters) { +

+ + {{ supporter.full_name }} + +

+ } }
} @@ -73,34 +73,32 @@

{{ 'Supporters' | translate }}

-@if (motion?.state) { +@if (motion.state$ | async; as motionState) {
- @if (motion.state) { - - @if (perms.isAllowed('change_state', motion)) { - @for (state of motion.state.next_states; track state) { - - } -
- @if (motion.state.previous_states.length > 0) { - + + @if (perms.isAllowed('change_state', motion)) { + @for (state of motionState.next_states; track state.id) { + + } +
+ @if (motionState.previous_states.length > 0) { + + @for (state of motionState.previous_states; track state.id) { } - @if (perms.isAllowed('change_metadata', motion)) { - - } - @if (perms.isAllowed('change_metadata', motion)) { - - } -
- } - @if (!perms.isAllowed('change_state', motion) && !!motion.state.submitter_withdraw_state) { - - } -
- } + } + @if (perms.isAllowed('change_metadata', motion)) { + + + } +
+ } @else if (!!motionState.submitter_withdraw_state) { + + } +
@if (showForwardButton) { @@ -140,11 +135,11 @@

{{ 'Supporters' | translate }}

} -@if (motion?.derived_motions?.length) { +@if (motion?.derived_motion_ids?.length) {

{{ 'Motion forwarded to' | translate }}

- @for (derived of motion.derived_motions; track derived; let last = $last) { + @for (derived of motion.derived_motions$ | async; track derived.id; let last = $last) {
({{ derived.forwarded | localizedDate }}) @@ -154,6 +149,7 @@

{{ 'Motion forwarded to' | translate }}

} +@let showReferringMotions = showReferringMotions$ | async; @if (isRecommendationEnabled) {
@@ -170,7 +166,7 @@

{{ 'Motion forwarded to' | translate }}

(succeeded)="setRecommendationExtension($event)" > - @for (recommendation of getPossibleRecommendations(); track recommendation) { + @for (recommendation of getPossibleRecommendations(); track recommendation.id) {
- } - @if (!motion.category) { + } @else { - } - } -
+
+ } } - -@if (tags.length) { -
- @if (perms.isAllowed('change_metadata', motion) || motion.hasTags()) { +@if (tags$ | async; as tags) { + @if (tags.length && (perms.isAllowed('change_metadata', motion) || motion.hasTags())) { +

{{ 'Tags' | translate }}

- } - @if (perms.isAllowed('change_metadata', motion) || motion.hasTags()) { - @for (tag of tags; track tag) { + @for (tag of tags; track tag.id) { } - @for (tag of motion.tags; track tag) { + @for (tag of motion.tags$ | async; track tag.id) { {{ tag }} } {{ '–' }} - } -
+
+ } } -@if (motionBlocks.length) { -
- @if (perms.isAllowed('change_metadata', motion) || motion.block) { +@if (motionBlocks$ | async; as motionBlocks) { + @if (motionBlocks.length && (perms.isAllowed('change_metadata', motion) || motion.block)) { +

{{ 'Motion block' | translate }}

- } - @if (perms.isAllowed('change_metadata', motion) || motion.block) { - @for (block of motionBlocks; track block) { + @for (block of motionBlocks; track block.id) {
+
+ } } @@ -346,34 +333,35 @@

{{ 'Creation date' | translate }}

} -@if (getOriginMotions().length) { -
-

{{ 'Origin' | translate }}

-
- @for (origin of getOriginMotions(); track origin; let last = $last) { -
- -
-
- @if (!last) { - north - } -
- } +@if (originMotions$ | async; as originMotions) { + @if (originMotions.length) { +
+

{{ 'Origin' | translate }}

+
+ @for (origin of originMotions; track origin.id; let last = $last) { +
+ +
+
+ @if (!last) { + north + } +
+ } +
-
+ } } -@if (amendments && amendments.length > 0) { +@if ((amendments$ | async).length; as amendmentAmount) {

{{ 'Amendments' | translate }}

- {{ amendments.length }} - @if (amendments.length === 1) { + {{ amendmentAmount }} + @if (amendmentAmount === 1) { {{ 'Amendment' | translate }} - } - @if (amendments.length > 1) { + } @else { {{ 'Amendments' | translate }} } @@ -397,8 +385,7 @@

{{ 'Amendments' | translate }}

{{ getMeetingName(motion) }} - } - @if (!canAccess(motion)) { + } @else { {{ getMeetingName(motion) }} diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-meta-data/motion-meta-data.component.scss b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-meta-data/motion-meta-data.component.scss similarity index 100% rename from client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-meta-data/motion-meta-data.component.scss rename to client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-meta-data/motion-meta-data.component.scss diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-meta-data/motion-meta-data.component.spec.ts b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-meta-data/motion-meta-data.component.spec.ts similarity index 100% rename from client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-meta-data/motion-meta-data.component.spec.ts rename to client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-meta-data/motion-meta-data.component.spec.ts diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-meta-data/motion-meta-data.component.ts b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-meta-data/motion-meta-data.component.ts similarity index 79% rename from client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-meta-data/motion-meta-data.component.ts rename to client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-meta-data/motion-meta-data.component.ts index 38ca714a17..e05c5f207c 100644 --- a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-meta-data/motion-meta-data.component.ts +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-meta-data/motion-meta-data.component.ts @@ -1,6 +1,6 @@ -import { Component, OnDestroy, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; -import { BehaviorSubject, distinctUntilChanged, map, Observable, Subscription } from 'rxjs'; +import { BehaviorSubject, map, Observable, Subscription } from 'rxjs'; import { Permission } from 'src/app/domain/definitions/permission'; import { Selectable } from 'src/app/domain/interfaces'; import { Settings } from 'src/app/domain/models/meetings/meeting'; @@ -14,32 +14,37 @@ import { ViewMeeting } from 'src/app/site/pages/meetings/view-models/view-meetin import { ViewUser } from 'src/app/site/pages/meetings/view-models/view-user'; import { OperatorService } from 'src/app/site/services/operator.service'; -import { ParticipantListSortService } from '../../../../../participants/pages/participant-list/services/participant-list-sort/participant-list-sort.service'; -import { MotionForwardDialogService } from '../../../../components/motion-forward-dialog/services/motion-forward-dialog.service'; -import { MotionEditorControllerService } from '../../../../modules/editors/services'; -import { MotionSubmitterControllerService } from '../../../../modules/submitters/services'; -import { MotionWorkingGroupSpeakerControllerService } from '../../../../modules/working-group-speakers/services'; -import { MotionPermissionService } from '../../../../services/common/motion-permission.service/motion-permission.service'; -import { BaseMotionDetailChildComponent } from '../../base/base-motion-detail-child.component'; +import { ParticipantListSortService } from '../../../../../../../participants/pages/participant-list/services/participant-list-sort/participant-list-sort.service'; +import { MotionForwardDialogService } from '../../../../../../components/motion-forward-dialog/services/motion-forward-dialog.service'; +import { MotionEditorControllerService } from '../../../../../../modules/editors/services'; +import { MotionSubmitterControllerService } from '../../../../../../modules/submitters/services'; +import { MotionWorkingGroupSpeakerControllerService } from '../../../../../../modules/working-group-speakers/services'; +import { MotionPermissionService } from '../../../../../../services/common/motion-permission.service/motion-permission.service'; +import { BaseMotionDetailChildComponent } from '../../../../base/base-motion-detail-child.component'; import { SearchListDefinition } from '../motion-extension-field/motion-extension-field.component'; @Component({ selector: `os-motion-meta-data`, templateUrl: `./motion-meta-data.component.html`, - styleUrls: [`./motion-meta-data.component.scss`] + styleUrls: [`./motion-meta-data.component.scss`], + changeDetection: ChangeDetectionStrategy.OnPush }) export class MotionMetaDataComponent extends BaseMotionDetailChildComponent implements OnInit, OnDestroy { - public motionBlocks: MotionBlock[] = []; + public categories$: Observable = this.categoryRepo.getViewModelListObservable(); + public tags$: Observable = this.tagRepo.getViewModelListObservable(); + public motionBlocks$: Observable = this.blockRepo.getViewModelListObservable(); - public categories: ViewMotionCategory[] = []; - - public tags: ViewTag[] = []; + @Input() + public changeRecoMode: ChangeRecoMode; /** * Determine if the name of supporters are visible */ public showSupporters = false; + public minSupporters$ = this.meetingSettingsService.get(`motions_supporters_min_amount`); + public showReferringMotions$ = this.meetingSettingsService.get(`motions_show_referring_motions`); + /** * @returns the current recommendation label (with extension) */ @@ -63,7 +68,7 @@ export class MotionMetaDataComponent extends BaseMotionDetailChildComponent impl } public get isDifferedChangeRecoMode(): boolean { - return this.viewService.currentChangeRecommendationMode === ChangeRecoMode.Diff; + return this.changeRecoMode === ChangeRecoMode.Diff; } /** @@ -86,10 +91,26 @@ export class MotionMetaDataComponent extends BaseMotionDetailChildComponent impl public motionTransformFn = (value: ViewMotion): string => `[${value.fqid}]`; - /** - * All amendments to this motion - */ - public override amendments: ViewMotion[] = []; + public get referencingMotions$(): Observable { + return this.motion?.referenced_in_motion_recommendation_extensions$.pipe( + map(motions => motions.naturalSort(this.translate.currentLang, [`number`, `title`])) + ); + } + + public get referencedMotions$(): Observable { + return this.motion?.recommendation_extension_references$.pipe( + map(motions => (motions as ViewMotion[]).naturalSort(this.translate.currentLang, [`number`, `title`])) + ); + } + + public get originMotions$(): Observable { + if (this.motion.origin_id) { + return this.motion.all_origins$.pipe(map(origins => origins?.reverse())); + } else if (this.motion.origin_meeting_id) { + return this.motion.origin_meeting$.pipe(map(origin => [origin])); + } + return null; + } public override set showAllAmendments(is: boolean) { this.viewService.showAllAmendmentsStateSubject.next(is); @@ -104,20 +125,8 @@ export class MotionMetaDataComponent extends BaseMotionDetailChildComponent impl ); } - public get referencingMotions(): ViewMotion[] { - return this._referencingMotions; - } - - public get referencedMotions(): ViewMotion[] { - return this._referencedMotions; - } - public loadForwardingCommittees: () => Promise; - private _referencingMotions: ViewMotion[]; - - private _referencedMotions: ViewMotion[]; - private _forwardingAvailable = false; public get supportersObservable(): Observable { @@ -148,6 +157,7 @@ export class MotionMetaDataComponent extends BaseMotionDetailChildComponent impl if (operator.hasPerms(Permission.motionCanManageMetadata)) { this.motionForwardingService.forwardingMeetingsAvailable().then(forwardingAvailable => { this._forwardingAvailable = forwardingAvailable; + this.cd.markForCheck(); this.loadForwardingCommittees = async (): Promise => { return (await this.checkPresenter()) as Selectable[]; }; @@ -170,6 +180,15 @@ export class MotionMetaDataComponent extends BaseMotionDetailChildComponent impl super.ngOnDestroy(); } + protected override onAfterInit(): void { + this.setupRecommender(); + } + + protected override onAfterSetMotion(previous: ViewMotion, current: ViewMotion): void { + super.onAfterSetMotion(previous, current); + this.updateSupportersSubject(); + } + /** * Sets the state * @@ -297,15 +316,6 @@ export class MotionMetaDataComponent extends BaseMotionDetailChildComponent impl return allStates.filter(state => state.recommendation_label).sort((a, b) => a.weight - b.weight); } - public getOriginMotions(): (ViewMotion | ViewMeeting)[] { - const copy = this.motion.origin_id - ? [...(this.motion.all_origins || [])] - : this.motion.origin_meeting - ? [this.motion.origin_meeting] - : []; - return copy.reverse(); - } - public getMeetingName(origin: ViewMotion | ViewMeeting): string { if (this.isViewMotion(origin)) { const motion = origin as ViewMotion; @@ -330,11 +340,6 @@ export class MotionMetaDataComponent extends BaseMotionDetailChildComponent impl return origin?.canAccess(); } - protected override onAfterSetMotion(previous: ViewMotion, current: ViewMotion): void { - super.onAfterSetMotion(previous, current); - this.updateSupportersSubject(); - } - private async updateSupportersSubject(): Promise { this._supportersSubject.next(await this.participantSort.sort(this.motion.supporters)); } @@ -343,32 +348,6 @@ export class MotionMetaDataComponent extends BaseMotionDetailChildComponent impl return toTest.COLLECTION === Motion.COLLECTION; } - protected override getSubscriptions(): Subscription[] { - return [ - this.amendmentRepo.getViewModelListObservableFor(this.motion).subscribe(value => (this.amendments = value)), - this.tagRepo.getViewModelListObservable().subscribe(value => (this.tags = value)), - this.categoryRepo.getViewModelListObservable().subscribe(value => (this.categories = value)), - this.blockRepo.getViewModelListObservable().subscribe(value => (this.motionBlocks = value)), - this.repo - .getViewModelObservable(this.motion.id) - .pipe( - map(motion => [ - motion?.referenced_in_motion_recommendation_extensions, - motion?.recommendation_extension_references as ViewMotion[] - ]), - distinctUntilChanged((p, c) => [...Array(2).keys()].every(i => p[i].equals(c[i]))), - map(arr => - arr.map(motions => (motions || []).naturalSort(this.translate.currentLang, [`number`, `title`])) - ) - ) - .subscribe(value => ([this._referencingMotions, this._referencedMotions] = value)) - ]; - } - - protected override onAfterInit(): void { - this.setupRecommender(); - } - /** * Observes the repository for changes in the motion recommender */ diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-personal-note/motion-personal-note.component.html b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-personal-note/motion-personal-note.component.html similarity index 77% rename from client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-personal-note/motion-personal-note.component.html rename to client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-personal-note/motion-personal-note.component.html index 7ed0e3f127..aa15c14735 100644 --- a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-personal-note/motion-personal-note.component.html +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-personal-note/motion-personal-note.component.html @@ -6,29 +6,26 @@ - @if (!isEditing) { - - } - @if (!isEditing && (personalNoteObservable | async)) { - - } @if (isEditing) { - } - @if (isEditing) { + } @else { + + @if (personalNoteObservable | async) { + + } } @@ -37,14 +34,12 @@ @if (!isEditing) { @if ((personalNoteObservable | async)?.note; as note) {
- } - @if ((hasPersonalNoteObservable | async) === false) { + } @else {
{{ 'No personal note' | translate }}
} - } - @if (isEditing) { + } @else { diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-personal-note/motion-personal-note.component.scss b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-personal-note/motion-personal-note.component.scss similarity index 100% rename from client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-personal-note/motion-personal-note.component.scss rename to client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-personal-note/motion-personal-note.component.scss diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-personal-note/motion-personal-note.component.spec.ts b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-personal-note/motion-personal-note.component.spec.ts similarity index 100% rename from client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-personal-note/motion-personal-note.component.spec.ts rename to client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-personal-note/motion-personal-note.component.spec.ts diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-personal-note/motion-personal-note.component.ts b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-personal-note/motion-personal-note.component.ts similarity index 89% rename from client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-personal-note/motion-personal-note.component.ts rename to client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-personal-note/motion-personal-note.component.ts index ebdc007c88..00389e4db0 100644 --- a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-personal-note/motion-personal-note.component.ts +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-personal-note/motion-personal-note.component.ts @@ -4,8 +4,8 @@ import { BehaviorSubject, map, Observable } from 'rxjs'; import { PersonalNote } from 'src/app/domain/models/motions/personal-note'; import { ViewPersonalNote } from 'src/app/site/pages/meetings/pages/motions'; -import { PersonalNoteControllerService } from '../../../../modules/personal-notes/services/personal-note-controller.service/personal-note-controller.service'; -import { MotionPdfExportService } from '../../../../services/export/motion-pdf-export.service/motion-pdf-export.service'; +import { PersonalNoteControllerService } from '../../../../../../modules/personal-notes/services/personal-note-controller.service/personal-note-controller.service'; +import { MotionPdfExportService } from '../../../../../../services/export/motion-pdf-export.service/motion-pdf-export.service'; import { BaseMotionDetailActionCardComponent } from '../../base/base-motion-detail-action-card.component'; const SUBSCRIPTION_NAME = `personal_note_subscription`; diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-view/motion-view.component.html b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-view/motion-view.component.html new file mode 100644 index 0000000000..17c53385cd --- /dev/null +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-view/motion-view.component.html @@ -0,0 +1,222 @@ + + @let hasLoaded = hasLoaded$ | async; + @let isMobile = vp.isMobileSubject | async; + + + @if (motion && hasLoaded) { +
+

+ @if (!isMobile) { + {{ 'Motion' | translate }} + +   + } + {{ motion.number }} +

+
+ } + + @if (showNavigateButtons) { +
+ + +
+ } + + @if (motion) { + + + + } + + @if (hasLoaded) { + + + + + + + + +
+ @if (motion) { + @if (!motion.agenda_item_id) { + + } @else { + + } + } +
+ +
+ +
+ +
+ +
+ @if (perms.isAllowed('update', motion) || perms.isAllowed('manage')) { + + + @if (perms.isAllowed('update', motion)) { + + } + + @if (perms.isAllowed('manage')) { + + } + } + } +
+
+ @if (hasLoaded && motion) { +
+ +
+ +
+ @if (isMobile) { +
+ + + + + + + + + + + + @if (!operator.isAnonymous) { + + } +
+ } @else { +
+
+ + + + @if (!operator.isAnonymous) { + + } +
+
+ + + + + + +
+
+ } +
+ } @else { +
+
+ +
+
+ } +
+ + +
+ +
+
+ + + + + + diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-detail-view/motion-detail-view.component.scss b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-view/motion-view.component.scss similarity index 94% rename from client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-detail-view/motion-detail-view.component.scss rename to client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-view/motion-view.component.scss index cbc7763a71..7d5abc6322 100644 --- a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-detail-view/motion-detail-view.component.scss +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-view/motion-view.component.scss @@ -166,3 +166,13 @@ button.motion-detail-nav-button { } } } + +.spinner-container { + min-height: calc(100vh - 100px); + + .spinner-inner-container { + display: flex; + flex-direction: column; + align-items: center; + } +} diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-detail-view/motion-detail-view.component.spec.ts b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-view/motion-view.component.spec.ts similarity index 58% rename from client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-detail-view/motion-detail-view.component.spec.ts rename to client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-view/motion-view.component.spec.ts index f17af1baaf..3677fc62fe 100644 --- a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/motion-detail-view/motion-detail-view.component.spec.ts +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-view/motion-view.component.spec.ts @@ -1,19 +1,19 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MotionDetailViewComponent } from './motion-detail-view.component'; +import { MotionViewComponent } from './motion-view.component'; xdescribe(`MotionDetailViewComponent`, () => { - let component: MotionDetailViewComponent; - let fixture: ComponentFixture; + let component: MotionViewComponent; + let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - declarations: [MotionDetailViewComponent] + declarations: [MotionViewComponent] }).compileComponents(); }); beforeEach(() => { - fixture = TestBed.createComponent(MotionDetailViewComponent); + fixture = TestBed.createComponent(MotionViewComponent); component = fixture.componentInstance; fixture.detectChanges(); }); 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 new file mode 100644 index 0000000000..af6e6174ae --- /dev/null +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/motion-view/motion-view.component.ts @@ -0,0 +1,489 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + HostListener, + OnDestroy, + OnInit, + ViewEncapsulation +} from '@angular/core'; +import { ActivatedRoute, RoutesRecognized } from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; +import { + auditTime, + BehaviorSubject, + combineLatest, + distinctUntilChanged, + filter, + firstValueFrom, + Observable +} from 'rxjs'; +import { Id } from 'src/app/domain/definitions/key-types'; +import { ChangeRecoMode, LineNumberingMode, PERSONAL_NOTE_ID } from 'src/app/domain/models/motions/motions.constants'; +import { BaseMeetingComponent } from 'src/app/site/pages/meetings/base/base-meeting.component'; +import { + ViewMotion, + ViewMotionChangeRecommendation, + ViewUnifiedChange +} from 'src/app/site/pages/meetings/pages/motions'; +import { OperatorService } from 'src/app/site/services/operator.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 { MotionChangeRecommendationControllerService } from '../../../../../../modules/change-recommendations/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 { MotionLineNumberingService } from '../../../../../../services/common/motion-line-numbering.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 { MotionDetailViewOriginUrlService } from '../../../../services/motion-detail-view-originurl.service'; + +@Component({ + selector: `os-motion-view`, + templateUrl: `./motion-view.component.html`, + styleUrls: [`./motion-view.component.scss`], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None +}) +export class MotionViewComponent extends BaseMeetingComponent implements OnInit, OnDestroy { + public readonly collection = ViewMotion.COLLECTION; + + /** + * Sets the motions, e.g. via an autoupdate. Reload important things here: + * - Reload the recommendation. Not changed with autoupdates, but if the motion is loaded this needs to run. + */ + public set motion(motion: ViewMotion) { + this._motion = motion; + } + + public get motion(): ViewMotion { + return this._motion; + } + + public hasChangeRecommendations: boolean = false; + public unifiedChanges$: BehaviorSubject = new BehaviorSubject([]); + + private get unifiedChanges(): ViewUnifiedChange[] { + return this.unifiedChanges$.value; + } + + /** + * preloaded next motion for direct navigation + */ + public nextMotion: ViewMotion | null = null; + public previousMotion: ViewMotion | null = null; + + public get showNavigateButtons(): boolean { + return !!this.previousMotion || !!this.nextMotion; + } + + private _changeRecoMode: ChangeRecoMode = ChangeRecoMode.Original; + public get changeRecoMode(): ChangeRecoMode { + return this._changeRecoMode; + } + + public set changeRecoMode(value: ChangeRecoMode) { + this._changeRecoMode = value; + this.cd.markForCheck(); + } + + private _lineNumberingMode: LineNumberingMode = LineNumberingMode.None; + public get lineNumberingMode(): LineNumberingMode { + return this._lineNumberingMode; + } + + public set lineNumberingMode(value: LineNumberingMode) { + this._lineNumberingMode = value; + this.cd.markForCheck(); + } + + public hasLoaded$ = new BehaviorSubject(false); + + /** + * List of presorted motions. Filles by sort service + * and filter service. + * To navigate back and forth + */ + private _sortedMotions: ViewMotion[] = []; + + /** + * The observable for the list of motions. Set in OnInit + */ + private _sortedMotionsObservable: Observable = null; + + private _motion: ViewMotion | null = null; + + private _amendmentsInMainList = false; + + private _navigatedFromAmendmentList = false; + + public constructor( + protected override translate: TranslateService, + public vp: ViewPortService, + public operator: OperatorService, + public perms: MotionPermissionService, + private route: ActivatedRoute, + public repo: MotionControllerService, + private promptService: PromptService, + private itemRepo: AgendaItemControllerService, + private motionSortService: MotionListSortService, + private motionFilterService: MotionListFilterService, + private motionForwardingService: MotionForwardDialogService, + private motionLineNumbering: MotionLineNumberingService, + private amendmentRepo: AmendmentControllerService, + private amendmentSortService: AmendmentListSortService, + private amendmentFilterService: AmendmentListFilterService, + private changeRecoRepo: MotionChangeRecommendationControllerService, + private cd: ChangeDetectorRef, + private pdfExport: MotionPdfExportService, + private originUrlService: MotionDetailViewOriginUrlService + ) { + super(); + + this.subscriptions.push( + this.router.events + .pipe( + filter((event): boolean => event instanceof RoutesRecognized), + distinctUntilChanged((p: RoutesRecognized, c: RoutesRecognized) => p?.url === c?.url) + ) + .subscribe(() => this.onMotionChange()) + ); + } + + /** + * Init. + * Sets all required subjects and fills in the required information + */ + public ngOnInit(): void { + this.isNavigatedFromAmendments(); + + this.subscriptions.push( + this.meetingSettingsService + .get(`motions_amendments_in_main_list`) + .subscribe(enabled => (this._amendmentsInMainList = enabled)), + this.meetingSettingsService + .get(`motions_default_line_numbering`) + .subscribe(val => (this.lineNumberingMode = val)), + this.meetingSettingsService + .get(`motions_recommendation_text_mode`) + .subscribe(val => (this.changeRecoMode = val)) + ); + } + + /** + * Called during view destruction. + * Sends a notification to user editors of the motion was edited + */ + public override ngOnDestroy(): void { + super.ngOnDestroy(); + this.amendmentSortService.exitSortService(); + this.motionSortService.exitSortService(); + } + + public onMotionChange(): void { + if (this.hasLoaded$.value) { + this.hasLoaded$.next(false); + } + + this.unifiedChanges$.next([]); + this.subscriptions.delete(`motion`); + this.subscriptions.delete(`sorted-changes`); + } + + public async onMotionIdFound(id: Id | null): Promise { + if (id) { + const lastMeetingId = this.motion?.meeting_id; + const motionSubscription = this.repo.getViewModelObservable(id).pipe(filter(m => !!m)); + this.subscriptions.updateSubscription( + `motion`, + motionSubscription.subscribe(motion => this.onMotionUpdated(motion)) + ); + + const motion = await firstValueFrom(motionSubscription); + if (lastMeetingId !== motion.meeting_id) { + this.isNavigatedFromAmendments(); + this._sortedMotionsObservable = null; + this.subscriptions.delete(`sorted-motions`); + } + await this.modelRequestService.waitSubscriptionReady(MOTION_DETAIL_SUBSCRIPTION); + if (motion.id !== id) { + return; + } + this.onMotionLoaded(); + } + + this.hasLoaded$.next(true); + } + + public onMotionLoaded(): void { + if (!this._sortedMotionsObservable) { + this.updateSortedMotionsObservable(); + } + this.nextMotionLoaded(); + } + + public onMotionUpdated(motion: ViewMotion): void { + const title = motion.getTitle(); + super.setTitle(title); + this.motion = motion; + this.cd.markForCheck(); + } + + /** + * 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); + } + } + + public goToHistory(): void { + this.router.navigate([this.activeMeetingId!, `history`], { queryParams: { fqid: this.motion.fqid } }); + } + + /** + * 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`]); + } + } + + /** + * 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 }); + } else { + this.router.navigate([this.activeMeetingId, `motions`, `new`], { + relativeTo: this.route.snapshot.params[`relativeTo`], + queryParams: { parent: this.motion.id || null } + }); + } + } + + /** + * Navigates the user to the given ViewMotion + * + * @param motion target + */ + public navigateToMotion(motion: ViewMotion | null): void { + if (motion) { + this.router.navigate([this.activeMeetingId, `motions`, motion.sequential_number]); + this.setSurroundingMotions(); + } + } + + public async forwardMotionToMeetings(): Promise { + await this.motionForwardingService.forwardMotionsToMeetings(this.motion); + } + + /** + * Click handler for the pdf button + */ + public downloadPdf(): void { + this.pdfExport.exportSingleMotion(this.motion, { + lnMode: + this.lineNumberingMode === LineNumberingMode.Inside + ? LineNumberingMode.Outside + : this.lineNumberingMode, + crMode: this.changeRecoMode, + // export all comment fields as well as personal note + comments: this.motion.usedCommentSectionIds.concat([PERSONAL_NOTE_ID]) + }); + } + + public addToAgenda(): void { + this.itemRepo.addToAgenda({}, this.motion).resolve().catch(this.raiseError); + } + + public removeFromAgenda(): void { + this.itemRepo.removeFromAgenda(this.motion.agenda_item_id!).catch(this.raiseError); + } + + private nextMotionLoaded(): void { + this.changeRecoMode = + this.meetingSettingsService.instant(`motions_recommendation_text_mode`) || ChangeRecoMode.Original; + + let previousAmendments: ViewMotion[] = null; + this.subscriptions.updateSubscription( + `sorted-changes`, + combineLatest([ + this.meetingSettingsService.get(`motions_line_length`), + this.changeRecoRepo.getChangeRecosOfMotionObservable(this.motion.id).pipe(filter(value => !!value)), + this.amendmentRepo.getViewModelListObservableFor(this.motion).pipe(filter(value => !!value)) + ]) + .pipe(auditTime(1)) // Needed to replicate behaviour of base-repository list updates + .subscribe(([lineLength, changeRecos, amendments]) => { + if (previousAmendments !== amendments) { + this.motionLineNumbering.resetAmendmentChangeRecoListeners(amendments); + previousAmendments = amendments; + } + this.hasChangeRecommendations = !!changeRecos?.length; + this.unifiedChanges$.next( + this.motionLineNumbering.recalcUnifiedChanges( + lineLength, + changeRecos as ViewMotionChangeRecommendation[], + amendments + ) + ); + this.changeRecoMode = this.determineCrMode(this.changeRecoMode); + this.cd.markForCheck(); + }) + ); + } + + private updateSortedMotionsObservable(): void { + // 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; + } + + if (this._sortedMotionsObservable) { + this.subscriptions.updateSubscription( + `sorted-motions`, + this._sortedMotionsObservable.subscribe(motions => { + if (motions) { + this._sortedMotions = motions; + this.setSurroundingMotions(); + } + }) + ); + } + } + + /** + * Sets the previous and next motion. Sorts by the current sorting as used + * in the {@link MotionSortListService} or {@link AmendmentSortListService}, + * respectively + */ + private 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); + } else { + this.nextMotion = null; + } + this.cd.markForCheck(); + } + + /** + * 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; + } + } + } + + /** + * 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]; + } + + 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; + } + + /** + * Tries to determine the realistic CR-Mode from a given CR mode + */ + private determineCrMode(mode: ChangeRecoMode): ChangeRecoMode { + if (mode === ChangeRecoMode.Final) { + if (this.motion?.modified_final_version) { + return ChangeRecoMode.ModifiedFinal; + /** + * Because without change recos you cannot escape the final version anymore + */ + } else if (!this.unifiedChanges.some(change => change.showInFinalView())) { + return ChangeRecoMode.Original; + } + } else if (mode === ChangeRecoMode.Changed && !this.hasChangeRecommendations) { + /** + * Because without change recos you cannot escape the changed version view + * You will not be able to automatically change to the Changed view after creating + * a change reco. The autoupdate has to come "after" this routine + */ + return ChangeRecoMode.Original; + } else if ( + mode === ChangeRecoMode.Diff && + !this.hasChangeRecommendations && + this.motion?.isParagraphBasedAmendment() + ) { + /** + * The Diff view for paragraph-based amendments is only relevant for change recommendations; + * the regular amendment changes are shown in the "original" view. + */ + return ChangeRecoMode.Original; + } + return mode; + } +} diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/paragraph-based-amendment/paragraph-based-amendment.component.html b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/paragraph-based-amendment/paragraph-based-amendment.component.html new file mode 100644 index 0000000000..50018f9875 --- /dev/null +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/paragraph-based-amendment/paragraph-based-amendment.component.html @@ -0,0 +1,104 @@ +
+ + @if (amendmentLines) { +
+ @if (amendmentLines.length === 0) { +
+ @if (motion.lead_motion) { + {{ 'No changes at the text.' | translate }} + } @else { + {{ 'The parent motion is not available.' | translate }} + } +
+ } + @if (amendmentErrorMessage) { +
+ +
+ } + @if (motion.lead_motion && !isFinalEdit) { + @if (changeRecoMode === ChangeRecoMode.Diff) { + + } + @for (paragraph of getAmendmentParagraphs(); track $index) { + + @if (changeRecoMode === ChangeRecoMode.Diff) { + + } + } + } @else if (changeRecoMode === ChangeRecoMode.Diff) { + + } +
+ } @else { +
+ + {{ 'There is an error with this amendment. Please edit it manually.' | translate }} + +
+ } +
+ +@if (changeRecoMode === ChangeRecoMode.Original || changeRecoMode === ChangeRecoMode.Changed) { +
+ @if (motion && motion.isParagraphBasedAmendment() && motion.lead_motion) { + + {{ 'Show entire motion text' | translate }} + + } +
+} diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/paragraph-based-amendment/paragraph-based-amendment.component.scss b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/paragraph-based-amendment/paragraph-based-amendment.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-view/components/paragraph-based-amendment/paragraph-based-amendment.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/components/paragraph-based-amendment/paragraph-based-amendment.component.spec.ts b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/paragraph-based-amendment/paragraph-based-amendment.component.spec.ts similarity index 100% rename from client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/components/paragraph-based-amendment/paragraph-based-amendment.component.spec.ts rename to client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/paragraph-based-amendment/paragraph-based-amendment.component.spec.ts diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/paragraph-based-amendment/paragraph-based-amendment.component.ts b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/paragraph-based-amendment/paragraph-based-amendment.component.ts new file mode 100644 index 0000000000..3f4d12e074 --- /dev/null +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/components/paragraph-based-amendment/paragraph-based-amendment.component.ts @@ -0,0 +1,108 @@ +import { ChangeDetectionStrategy, Component, ElementRef, EventEmitter, Input, Output } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { UnsafeHtml } from 'src/app/domain/definitions/key-types'; +import { ChangeRecoMode, LineNumberingMode } from 'src/app/domain/models/motions/motions.constants'; +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 { DiffLinesInParagraph } from '../../../../../../definitions/index'; +import { BaseMotionDetailChildComponent } from '../../../../base/base-motion-detail-child.component'; + +@Component({ + selector: `os-paragraph-based-amendment`, + templateUrl: `./paragraph-based-amendment.component.html`, + styleUrls: [`./paragraph-based-amendment.component.scss`], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ParagraphBasedAmendmentComponent extends BaseMotionDetailChildComponent { + public readonly LineNumberingMode = LineNumberingMode; + public readonly ChangeRecoMode = ChangeRecoMode; + + @Input() + public changesForDiffMode: ViewUnifiedChange[] = []; + + @Input() + public highlightedLine!: number; + + @Input() + public isFinalEdit = false; + + @Input() + public lineNumberingMode: LineNumberingMode; + + private _changeRecoMode: ChangeRecoMode; + + @Input() + public set changeRecoMode(value: ChangeRecoMode) { + this._changeRecoMode = value; + this.showAmendmentContext = false; + } + + public get changeRecoMode(): ChangeRecoMode { + return this._changeRecoMode; + } + + @Output() + public createChangeRecommendation = new EventEmitter(); + + @Output() + public gotoChangeRecommendation = new EventEmitter(); + + public scrollToChange: ViewUnifiedChange | null = null; + + public showAmendmentContext = false; + + public amendmentErrorMessage: string | null = null; + + public get amendmentLines(): DiffLinesInParagraph[] | null { + return this.motion?.changedAmendmentLines; + } + + public get nativeElement(): any { + return this.el.nativeElement; + } + + public constructor( + protected override translate: TranslateService, + private el: ElementRef + ) { + super(); + } + + /** + * This returns the plain HTML of a changed area in an amendment, including its context, + * for the purpose of piping it into . + * This component works with plain HTML, hence we are composing plain HTML here, too. + * + * @param {DiffLinesInParagraph} paragraph + * @returns {string} + * + * TODO: Seems to be directly duplicated in the slide + */ + public getAmendmentDiffTextWithContext(paragraph: DiffLinesInParagraph): UnsafeHtml { + return ( + `
${paragraph.textPre}
` + + `
${paragraph.text}
` + + `
${paragraph.textPost}
` + ); + } + + /** + * If `this.motion` is an amendment, this returns the list of all changed paragraphs. + * + * @returns {DiffLinesInParagraph[]} + */ + public getAmendmentParagraphs(): DiffLinesInParagraph[] { + try { + this.amendmentErrorMessage = null; + return this.motion?.getAmendmentParagraphLines(this.changeRecoMode, this.showAmendmentContext) || []; + } catch (e: any) { + this.amendmentErrorMessage = e.toString(); + return []; + } + } + + public getAmendmentParagraphLinesTitle(paragraph: DiffLinesInParagraph): string { + return this.motion?.getParagraphTitleByParagraph(paragraph) || ``; + } +} diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/motion-view-routing.module.ts b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/motion-view-routing.module.ts new file mode 100644 index 0000000000..59e0d1cd2c --- /dev/null +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/motion-view-routing.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +import { MotionViewComponent } from './components/motion-view/motion-view.component'; + +const routes: Routes = [{ path: ``, component: MotionViewComponent, pathMatch: `full` }]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class MotionViewRoutingModule {} diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/motion-view.module.ts b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/motion-view.module.ts new file mode 100644 index 0000000000..71292d4e2d --- /dev/null +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/pages/motion-view/motion-view.module.ts @@ -0,0 +1,11 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { OpenSlidesTranslationModule } from 'src/app/site/modules/translations'; + +import { MotionViewRoutingModule } from './motion-view-routing.module'; + +@NgModule({ + declarations: [], + imports: [CommonModule, MotionViewRoutingModule, OpenSlidesTranslationModule.forChild()] +}) +export class MotionViewModule {} diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/services/motion-detail-service-collector.service/motion-detail-service-collector.service.spec.ts b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/services/motion-detail-service-collector.service/motion-detail-service-collector.service.spec.ts deleted file mode 100644 index e2d8b15024..0000000000 --- a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/services/motion-detail-service-collector.service/motion-detail-service-collector.service.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { TestBed } from '@angular/core/testing'; - -import { MotionDetailServiceCollectorService } from './motion-detail-service-collector.service'; - -xdescribe(`MotionDetailServiceCollectorService`, () => { - let service: MotionDetailServiceCollectorService; - - beforeEach(() => { - TestBed.configureTestingModule({}); - service = TestBed.inject(MotionDetailServiceCollectorService); - }); - - it(`should be created`, () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/services/motion-detail-service-collector.service/motion-detail-service-collector.service.ts b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/services/motion-detail-service-collector.service/motion-detail-service-collector.service.ts deleted file mode 100644 index e0e083d247..0000000000 --- a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/services/motion-detail-service-collector.service/motion-detail-service-collector.service.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Injectable } from '@angular/core'; -import { ParticipantControllerService } from 'src/app/site/pages/meetings/pages/participants/services/common/participant-controller.service/participant-controller.service'; - -import { MotionCategoryControllerService } from '../../../../modules/categories/services/motion-category-controller.service/motion-category-controller.service'; -import { MotionChangeRecommendationControllerService } from '../../../../modules/change-recommendations/services/motion-change-recommendation-controller.service/motion-change-recommendation-controller.service'; -import { MotionBlockControllerService } from '../../../../modules/motion-blocks/services/motion-block-controller.service/motion-block-controller.service'; -import { TagControllerService } from '../../../../modules/tags/services/tag-controller.service/tag-controller.service'; -import { MotionWorkflowControllerService } from '../../../../modules/workflows/services/motion-workflow-controller.service/motion-workflow-controller.service'; -import { AmendmentControllerService } from '../../../../services/common/amendment-controller.service'; -import { MotionControllerService } from '../../../../services/common/motion-controller.service/motion-controller.service'; -import { MotionFormatService } from '../../../../services/common/motion-format.service/motion-format.service'; -import { MotionLineNumberingService } from '../../../../services/common/motion-line-numbering.service/motion-line-numbering.service'; -import { MotionDetailServiceModule } from '../motion-detail-service.module'; -import { MotionDetailViewService } from '../motion-detail-view.service'; - -@Injectable({ - providedIn: MotionDetailServiceModule -}) -export class MotionDetailServiceCollectorService { - public constructor( - public changeRecoRepo: MotionChangeRecommendationControllerService, - public participantRepo: ParticipantControllerService, - public motionRepo: MotionControllerService, - public amendmentRepo: AmendmentControllerService, - public workflowRepo: MotionWorkflowControllerService, - public categoryRepo: MotionCategoryControllerService, - public blockRepo: MotionBlockControllerService, - public tagRepo: TagControllerService, - public motionLineNumbering: MotionLineNumberingService, - public motionViewService: MotionDetailViewService, - public motionFormatService: MotionFormatService - ) {} -} diff --git a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/services/motion-detail-view.service/motion-detail-view.service.ts b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/services/motion-detail-view.service/motion-detail-view.service.ts index 280d0803f3..abadbb4c56 100644 --- a/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/services/motion-detail-view.service/motion-detail-view.service.ts +++ b/client/src/app/site/pages/meetings/pages/motions/pages/motion-detail/services/motion-detail-view.service/motion-detail-view.service.ts @@ -1,6 +1,5 @@ import { Injectable } from '@angular/core'; import { BehaviorSubject, Subject } from 'rxjs'; -import { ChangeRecoMode, LineNumberingMode } from 'src/app/domain/models/motions/motions.constants'; import { MotionDetailServiceModule } from '../motion-detail-service.module'; @@ -14,20 +13,10 @@ export enum ModifiedFinalVersionAction { providedIn: MotionDetailServiceModule }) export class MotionDetailViewService { - public get currentLineNumberingMode(): LineNumberingMode { - return this.lineNumberingModeSubject.value; - } - - public get currentChangeRecommendationMode(): ChangeRecoMode { - return this.changeRecommendationModeSubject.value; - } - public get currentShowAllAmendmentsState(): boolean { return this.showAllAmendmentsStateSubject.value; } - public readonly lineNumberingModeSubject = new BehaviorSubject(LineNumberingMode.None); - public readonly changeRecommendationModeSubject = new BehaviorSubject(ChangeRecoMode.Original); public readonly showAllAmendmentsStateSubject = new BehaviorSubject(false); public readonly modifiedFinalVersionActionSubject = new Subject(); @@ -36,7 +25,5 @@ export class MotionDetailViewService { */ public reset(): void { this.showAllAmendmentsStateSubject.next(false); - this.changeRecommendationModeSubject.next(ChangeRecoMode.Original); - this.lineNumberingModeSubject.next(LineNumberingMode.None); } } diff --git a/client/src/app/site/pages/meetings/pages/motions/services/common/amendment-controller.service/amendment-controller.service.ts b/client/src/app/site/pages/meetings/pages/motions/services/common/amendment-controller.service/amendment-controller.service.ts index af67de8d84..2491e5cf78 100644 --- a/client/src/app/site/pages/meetings/pages/motions/services/common/amendment-controller.service/amendment-controller.service.ts +++ b/client/src/app/site/pages/meetings/pages/motions/services/common/amendment-controller.service/amendment-controller.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { map, Observable } from 'rxjs'; +import { distinctUntilChanged, map, Observable } from 'rxjs'; import { Id } from 'src/app/domain/definitions/key-types'; import { Identifiable } from 'src/app/domain/interfaces'; import { Motion } from 'src/app/domain/models/motions/motion'; @@ -42,7 +42,13 @@ export class AmendmentControllerService { public getViewModelListObservableFor(motion: Identifiable): Observable { return this.getViewModelListObservable().pipe( - map(_motions => _motions.filter(_motion => _motion.lead_motion_id === motion.id)) + map(_motions => _motions.filter(_motion => _motion.lead_motion_id === motion.id)), + distinctUntilChanged( + (prev, curr) => + prev?.length === curr?.length && + Math.max(...prev.map(e => e.viewModelUpdateTimestamp)) === + Math.max(...curr.map(e => e.viewModelUpdateTimestamp)) + ) ); } diff --git a/client/src/app/site/pages/meetings/pages/motions/view-models/view-motion.ts b/client/src/app/site/pages/meetings/pages/motions/view-models/view-motion.ts index 6ddd37cb5e..1bb761bd2e 100644 --- a/client/src/app/site/pages/meetings/pages/motions/view-models/view-motion.ts +++ b/client/src/app/site/pages/meetings/pages/motions/view-models/view-motion.ts @@ -35,10 +35,11 @@ import { ViewMotionSubmitter } from '../modules/submitters'; import { HasTags } from '../modules/tags/view-models/has-tags'; import { ViewMotionWorkingGroupSpeaker } from '../modules/working-group-speakers'; -export interface HasReferencedMotionsInExtension extends HasReferencedMotionInExtensionIds { - referenced_in_motion_state_extensions: ViewMotion[]; - referenced_in_motion_recommendation_extensions: ViewMotion[]; -} +export type HasReferencedMotionsInExtension = HasReferencedMotionInExtensionIds & + ViewModelRelations<{ + referenced_in_motion_state_extensions: ViewMotion[]; + referenced_in_motion_recommendation_extensions: ViewMotion[]; + }>; export enum ForwardingStatus { none = `none`, diff --git a/client/src/app/site/pages/meetings/services/active-meeting-id.service.ts b/client/src/app/site/pages/meetings/services/active-meeting-id.service.ts index 954c150c6e..1cb6ef7b71 100644 --- a/client/src/app/site/pages/meetings/services/active-meeting-id.service.ts +++ b/client/src/app/site/pages/meetings/services/active-meeting-id.service.ts @@ -54,15 +54,22 @@ export class ActiveMeetingIdService { distinctUntilChanged() ) .subscribe(event => { - const parts = (event as RoutesRecognized).url.split(`/`); - let meetingId = null; - if (parts.length >= 2) { - meetingId = parts[1]; - } - this.setMeetingId(meetingId); + this.setMeetingId(this.parseUrlMeetingId((event as RoutesRecognized).url)); }); } + /** + * Returns the meeting id from the current route + */ + public parseUrlMeetingId(url: string): Id | null { + const parts = url.split(`/`); + if (parts.length >= 2) { + return +parts[1]; + } + + return null; + } + private setMeetingId(nextMeetingId: number | null | undefined): void { nextMeetingId = Number(nextMeetingId) || null; diff --git a/client/src/app/site/pages/meetings/services/active-meeting.service.ts b/client/src/app/site/pages/meetings/services/active-meeting.service.ts index 60fd39c76b..285185475f 100644 --- a/client/src/app/site/pages/meetings/services/active-meeting.service.ts +++ b/client/src/app/site/pages/meetings/services/active-meeting.service.ts @@ -62,9 +62,6 @@ export class ActiveMeetingService { this.lifecycle.openslidesBooted.subscribe(); } - /** - * Only used in the `OperatorService` - */ public async ensureActiveMeetingIsAvailable(): Promise { if (!!this.meetingId) { return await firstValueFrom( diff --git a/client/src/app/site/services/autoupdate/autoupdate-communication.service.ts b/client/src/app/site/services/autoupdate/autoupdate-communication.service.ts index b9cca68d73..4a714aa806 100644 --- a/client/src/app/site/services/autoupdate/autoupdate-communication.service.ts +++ b/client/src/app/site/services/autoupdate/autoupdate-communication.service.ts @@ -14,6 +14,7 @@ import { AutoupdateCloseStream, AutoupdateOpenStream, AutoupdateReceiveData, + AutoupdateReceiveDataContent, AutoupdateReceiveError, AutoupdateReconnectInactive, AutoupdateSetConnectionStatus, @@ -34,7 +35,7 @@ import { SUBSCRIPTION_SUFFIX } from '../model-request.service'; providedIn: `root` }) export class AutoupdateCommunicationService { - private autoupdateDataObservable: Observable; + private autoupdateDataObservable: Observable; private openResolvers = new Map) => void>(); private endpointName: string; private autoupdateEndpointStatus: 'healthy' | 'unhealthy' = `healthy`; @@ -187,7 +188,7 @@ export class AutoupdateCommunicationService { /** * @returns Observable containing messages from autoupdate */ - public listen(): Observable { + public listen(): Observable { return this.autoupdateDataObservable; } diff --git a/client/src/app/site/services/autoupdate/autoupdate.service.ts b/client/src/app/site/services/autoupdate/autoupdate.service.ts index 4a81637eff..b0ceff753b 100644 --- a/client/src/app/site/services/autoupdate/autoupdate.service.ts +++ b/client/src/app/site/services/autoupdate/autoupdate.service.ts @@ -44,6 +44,7 @@ interface AutoupdateSubscriptionMap { interface AutoupdateIncomingMessage { autoupdateData: AutoupdateModelData; + autoupdateDataId: Id; id: Id; description?: string; } @@ -73,6 +74,8 @@ export class AutoupdateService { private _mutex = new Mutex(); private _currentQueryParams: QueryParams | null = null; private _resolveDataReceived: ((value: ModelData) => void)[] = []; + private _lastHandeledDataId: Id; + private _lastHandeledDataRequestId: Id; public constructor( private httpEndpointService: HttpStreamEndpointService, @@ -90,7 +93,12 @@ export class AutoupdateService { this.communication.setEndpoint(AUTOUPDATE_DEFAULT_ENDPOINT); this.communication.listen().subscribe(data => { - this.handleAutoupdate({ autoupdateData: data.data, id: data.streamId, description: data.description }); + this.handleAutoupdate({ + autoupdateData: data.data, + autoupdateDataId: data.dataId, + id: data.streamId, + description: data.description + }); }); this.communication.listenShouldReconnect().subscribe(() => { this.pauseUntilVisible(); @@ -246,13 +254,30 @@ export class AutoupdateService { }; } - private async handleAutoupdate({ autoupdateData, id, description }: AutoupdateIncomingMessage): Promise { + private async handleAutoupdate({ + autoupdateData, + autoupdateDataId, + id, + description + }: AutoupdateIncomingMessage): Promise { if (!this._activeRequestObjects || !this._activeRequestObjects[id]) { return; } const modelData = autoupdateFormatToModelData(autoupdateData); console.debug(`[autoupdate] from stream:`, description, id, [modelData, autoupdateData]); + if (this._lastHandeledDataId === autoupdateDataId) { + const unlock = await this._mutex.lock(); + if (this._resolveDataReceived[id]) { + await this._activeRequestObjects[this._lastHandeledDataRequestId]?.modelSubscription?.receivedData; + this._resolveDataReceived[id](modelData); + delete this._resolveDataReceived[id]; + } + return unlock(); + } + this._lastHandeledDataId = autoupdateDataId; + this._lastHandeledDataRequestId = id; + const fullListUpdateCollections: { [collection: string]: Ids; } = {}; 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 f004c0b96a..ab64af22e2 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 @@ -241,7 +241,14 @@ export class EditorComponent extends BaseFormControlComponent implements public override ngOnDestroy(): void { super.ngOnDestroy(); - this.editor.destroy(); + this.editor?.destroy(); + } + + public override writeValue(value: string): void { + super.writeValue(value); + if (this.editor) { + this.editor.commands.setContent(value); + } } public updateColorSets(): void { diff --git a/client/src/app/ui/modules/sidenav/modules/easter-egg/modules/chess-dialog/components/chess-dialog/chess-dialog.component.html b/client/src/app/ui/modules/sidenav/modules/easter-egg/modules/chess-dialog/components/chess-dialog/chess-dialog.component.html index 15357cc862..2bf99692e1 100644 --- a/client/src/app/ui/modules/sidenav/modules/easter-egg/modules/chess-dialog/components/chess-dialog/chess-dialog.component.html +++ b/client/src/app/ui/modules/sidenav/modules/easter-egg/modules/chess-dialog/components/chess-dialog/chess-dialog.component.html @@ -5,7 +5,7 @@

- + @if (opponentName) {
{{ 'Playing against' | translate }} {{ opponentName }}
} diff --git a/client/src/app/ui/modules/sidenav/modules/easter-egg/modules/chess-dialog/components/chess-dialog/chess-dialog.component.scss b/client/src/app/ui/modules/sidenav/modules/easter-egg/modules/chess-dialog/components/chess-dialog/chess-dialog.component.scss index 974faf333c..53062b71d3 100644 --- a/client/src/app/ui/modules/sidenav/modules/easter-egg/modules/chess-dialog/components/chess-dialog/chess-dialog.component.scss +++ b/client/src/app/ui/modules/sidenav/modules/easter-egg/modules/chess-dialog/components/chess-dialog/chess-dialog.component.scss @@ -25,3 +25,7 @@ .padding-bottom-0 { padding-bottom: 0px; } + +.scrollable { + overflow: auto; +} diff --git a/client/src/app/worker/autoupdate/autoupdate-subscription.ts b/client/src/app/worker/autoupdate/autoupdate-subscription.ts index 18bfe8729c..c5b191f795 100644 --- a/client/src/app/worker/autoupdate/autoupdate-subscription.ts +++ b/client/src/app/worker/autoupdate/autoupdate-subscription.ts @@ -51,12 +51,14 @@ export class AutoupdateSubscription { * @param data The data to be processed */ public updateData(data: any): void { + const dataId = Date.now(); for (const port of this.ports) { port.postMessage({ sender: `autoupdate`, action: `receive-data`, content: { streamId: this.id, + dataId, data: data, description: this.description } @@ -122,12 +124,14 @@ export class AutoupdateSubscription { * @param port The MessagePort the data should be send to */ public resendTo(port: MessagePort): void { + const dataId = Date.now(); if (this.stream && this.stream.currentData !== null) { port.postMessage({ sender: `autoupdate`, action: `receive-data`, content: { streamId: this.id, + dataId, data: this.stream.currentData, description: this.description } diff --git a/client/src/app/worker/autoupdate/interfaces-autoupdate.ts b/client/src/app/worker/autoupdate/interfaces-autoupdate.ts index 13d5270ee8..fceaaf50e3 100644 --- a/client/src/app/worker/autoupdate/interfaces-autoupdate.ts +++ b/client/src/app/worker/autoupdate/interfaces-autoupdate.ts @@ -89,6 +89,7 @@ export interface AutoupdateSetStreamId extends AutoupdateWorkerResponse { export interface AutoupdateReceiveDataContent { streamId: Id; + dataId: Id; data: any; description: string; }