diff --git a/client/src/app/domain/interfaces/constructable.ts b/client/src/app/domain/interfaces/constructable.ts index 441b9e4428..ae9d864df6 100644 --- a/client/src/app/domain/interfaces/constructable.ts +++ b/client/src/app/domain/interfaces/constructable.ts @@ -1,7 +1,7 @@ import { Fqid } from 'src/app/domain/definitions/key-types'; export interface Constructable { new (...args: any[]): T; - prototype: string; + prototype: T; name?: string; } diff --git a/client/src/app/gateways/actions/action.service.ts b/client/src/app/gateways/actions/action.service.ts index e08c839001..9cf3351170 100644 --- a/client/src/app/gateways/actions/action.service.ts +++ b/client/src/app/gateways/actions/action.service.ts @@ -3,17 +3,34 @@ import { HttpService } from '../http.service'; import { Action } from './action'; import { ActionRequest, isActionError, isActionResponse } from './action-utils'; +type ActionFn = () => boolean; +const ACTION_URL = `/system/action/handle_request`; + +let uniqueFnId = 0; + @Injectable({ providedIn: 'root' }) export class ActionService { - private readonly ACTION_URL = `/system/action/handle_request`; + private readonly _beforeActionFnMap: { [index: number]: ActionFn } = {}; public constructor(private http: HttpService) {} + public addBeforeActionFn(fn: () => boolean): number { + this._beforeActionFnMap[++uniqueFnId] = fn; + return uniqueFnId; + } + + public removeBeforeActionFn(index: number): void { + delete this._beforeActionFnMap[index]; + } + public async sendRequests(requests: ActionRequest[]): Promise { + if (!this.isAllowed()) { + return null; + } console.log(`send requests:`, requests); - const response = await this.http.post(this.ACTION_URL, requests); + const response = await this.http.post(ACTION_URL, requests); if (isActionError(response)) { throw response.message; } else if (isActionResponse(response)) { @@ -33,6 +50,14 @@ export class ActionService { return new Action(r => this.sendRequests(r) as any, ...requests); } + private isAllowed(): boolean { + const functions = Object.values(this._beforeActionFnMap); + if (!functions.length) { + return true; + } + return functions.some(fn => !fn()); + } + ///////////////////////////////////////////////////////////////////////////////////// /////////////////////// The following methods will be removed /////////////////////// ///////////////////////////////////////////////////////////////////////////////////// diff --git a/client/src/app/gateways/http-stream/http-stream.service.ts b/client/src/app/gateways/http-stream/http-stream.service.ts index ca2157decc..5eb014218d 100644 --- a/client/src/app/gateways/http-stream/http-stream.service.ts +++ b/client/src/app/gateways/http-stream/http-stream.service.ts @@ -15,6 +15,9 @@ const lostConnectionToFn = (endpoint: EndpointConfiguration) => { type HttpParamsGetter = () => HttpParams | { [param: string]: string | string[] } | null; type HttpBodyGetter = () => any; + +type CreateEndpointFn = { endpointIndex: string; customUrlFn: (baseEndpointUrl: string) => string }; + interface RequestOptions { bodyFn?: HttpBodyGetter; paramsFn?: HttpParamsGetter; @@ -32,7 +35,7 @@ export class HttpStreamService { ) {} public create( - endpointConfiguration: string | EndpointConfiguration, + endpointConfiguration: string | CreateEndpointFn | EndpointConfiguration, { onError = (_, description) => this.onError(this.getEndpointConfiguration(endpointConfiguration), description), @@ -98,11 +101,16 @@ export class HttpStreamService { return true; } - private getEndpointConfiguration(endpoint: string | EndpointConfiguration): EndpointConfiguration { + private getEndpointConfiguration( + endpoint: string | CreateEndpointFn | EndpointConfiguration + ): EndpointConfiguration { if (typeof endpoint === `string`) { return this.endpointService.getEndpoint(endpoint); - } else { + } else if (endpoint instanceof EndpointConfiguration) { return endpoint; + } else { + const configuration = this.endpointService.getEndpoint(endpoint.endpointIndex); + return { ...configuration, url: endpoint.customUrlFn(configuration.url) }; } } } diff --git a/client/src/app/gateways/notify.service.ts b/client/src/app/gateways/notify.service.ts index 69cc3c6a69..944b4a639d 100644 --- a/client/src/app/gateways/notify.service.ts +++ b/client/src/app/gateways/notify.service.ts @@ -143,7 +143,60 @@ export class NotifyService { }); } - private async connect(meetingId: number): Promise { + /** + * Returns a general observalbe of all notify messages. + */ + public getObservable(): Observable> { + return this.notifySubject.asObservable(); + } + + /** + * Returns an observable which gets updates for a specific topic. + * @param name The name of a topic to subscribe to. + */ + public getMessageObservable(name: string): Observable> { + if (!this.messageSubjects[name]) { + this.messageSubjects[name] = new Subject>(); + } + return this.messageSubjects[name].asObservable() as Observable>; + } + + /** + * Sents a notify message to all users (so all clients that are online). + * @param name The name of the notify message + * @param content The payload to send + */ + public async sendToAllUsers(name: string, content: T): Promise { + await this.send({ name, message: content, toAll: true }); + } + + /** + * Sends a notify message to all open clients with the given users logged in. + * @param name The name of the enotify message + * @param content The payload to send. + * @param users Multiple user ids. + */ + public async sendToUsers(name: string, content: T, ...users: number[]): Promise { + if (users.length < 1) { + throw new Error(`You have to provide at least one user`); + } + await this.send({ name, message: content, users }); + } + + /** + * Sends a notify message to all given channels. + * @param name The name of th enotify message + * @param content The payload to send. + * @param channels Multiple channels to send this message to. + */ + public async sendToChannels(name: string, content: T, ...channels: string[]): Promise { + if (channels.length < 1) { + throw new Error(`You have to provide at least one channel`); + } + await this.send({ name, message: content, channels }); + } + + public async connect(meetingId: number): Promise { if (!meetingId) { throw new Error(`Cannot connect to ICC, no meeting ID was provided`); } @@ -168,7 +221,7 @@ export class NotifyService { this.connectionClosingFn = closeFn; } - private disconnect(): void { + public disconnect(): void { if (this.connectionClosingFn) { try { this.connectionClosingFn(); @@ -193,81 +246,28 @@ export class NotifyService { } } - /** - * Sents a notify message to all users (so all clients that are online). - * @param name The name of the notify message - * @param content The payload to send - */ - public async sendToAllUsers(name: string, content: T): Promise { - await this.send({ name, message: content, toAll: true }); - } - - /** - * Sends a notify message to all open clients with the given users logged in. - * @param name The name of the enotify message - * @param content The payload to send. - * @param users Multiple user ids. - */ - public async sendToUsers(name: string, content: T, ...users: number[]): Promise { - if (users.length < 1) { - throw new Error(`You have to provide at least one user`); - } - await this.send({ name, message: content, users }); - } - - /** - * Sends a notify message to all given channels. - * @param name The name of th enotify message - * @param content The payload to send. - * @param channels Multiple channels to send this message to. - */ - public async sendToChannels(name: string, content: T, ...channels: string[]): Promise { - if (channels.length < 1) { - throw new Error(`You have to provide at least one channel`); - } - await this.send({ name, message: content, channels }); - } - /** * General send function for notify messages. */ private async send({ name, message, toAll, users, channels }: NotifySendOptions): Promise { - // if (!this.channelId) { - // throw new Error(`No channel id!`); - // } - // const notify: NotifyRequest = { - // name, - // message, - // channel_id: this.channelId, - // to_meeting: this.activeMeetingIdService.meetingId! - // }; - // if (toAll === true) { - // notify.to_all = true; - // } - // if (users) { - // notify.to_users = users; - // } - // if (channels) { - // notify.to_channels = channels; - // } - // await this.httpService.post(PUBLISH_PATH, notify); - } - - /** - * Returns a general observalbe of all notify messages. - */ - public getObservable(): Observable> { - return this.notifySubject.asObservable(); - } - - /** - * Returns an observable which gets updates for a specific topic. - * @param name The name of a topic to subscribe to. - */ - public getMessageObservable(name: string): Observable> { - if (!this.messageSubjects[name]) { - this.messageSubjects[name] = new Subject>(); + if (!this.channelId) { + throw new Error(`No channel id!`); } - return this.messageSubjects[name].asObservable() as Observable>; + const notify: NotifyRequest = { + name, + message, + channel_id: this.channelId, + to_meeting: this.activeMeetingIdService.meetingId! + }; + if (toAll === true) { + notify.to_all = true; + } + if (users) { + notify.to_users = users; + } + if (channels) { + notify.to_channels = channels; + } + await this.httpService.post(PUBLISH_PATH, notify); } } diff --git a/client/src/app/gateways/presenter/history-presenter.service.spec.ts b/client/src/app/gateways/presenter/history-presenter.service.spec.ts new file mode 100644 index 0000000000..75d4aa2184 --- /dev/null +++ b/client/src/app/gateways/presenter/history-presenter.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { HistoryPresenterService } from './history-presenter.service'; + +describe('HistoryPresenterService', () => { + let service: HistoryPresenterService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(HistoryPresenterService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/app/gateways/presenter/history-presenter.service.ts b/client/src/app/gateways/presenter/history-presenter.service.ts new file mode 100644 index 0000000000..a03421ebf2 --- /dev/null +++ b/client/src/app/gateways/presenter/history-presenter.service.ts @@ -0,0 +1,92 @@ +import { Injectable } from '@angular/core'; +import { Collection, Fqid, Id } from 'src/app/domain/definitions/key-types'; +import { HttpService } from 'src/app/gateways/http.service'; +import { UserRepositoryService } from 'src/app/gateways/repositories/users'; +import { collectionFromFqid } from 'src/app/infrastructure/utils/transform-functions'; + +interface InformationObject { + [fqid: string]: string[]; +} + +export class Position { + public position: number; + public timestamp: number; + public information: InformationObject; + public user_id: Id; + public fqid: Fqid; + + public constructor(input?: Partial) { + if (input) { + Object.assign(this, input); + } + } +} + +export class HistoryPosition extends Position { + public user: string; + + public get date(): Date { + return new Date(this.timestamp * 1000); + } + + private get _collection(): Collection { + return collectionFromFqid(this.fqid); + } + + public constructor(input?: Partial) { + super(input); + if (input) { + Object.assign(this, input); + } + } + + /** + * Converts the date (this.now) to a time and date string. + * + * @param locale locale indicator, i.e 'de-DE' + * @returns a human readable kind of time and date representation + */ + public getLocaleString(locale: string): string { + return this.date.toLocaleString(locale); + } + + public getPositionDescriptions(): string[] { + const information = this.information[this.fqid]; + return information.map(entry => entry.replace(`Object`, this._collection)); + } +} + +interface HistoryPresenterResponse { + [fqid: string]: Position[]; +} + +const HISTORY_ENDPOINT = `/system/autoupdate/history_information`; + +const getUniqueItems = (positions: Position[]) => { + const positionMap: { [positionNumber: number]: Position } = {}; + for (const position of positions) { + positionMap[position.position] = position; + } + return Object.values(positionMap); +}; + +@Injectable({ + providedIn: 'root' +}) +export class HistoryPresenterService { + public constructor(private http: HttpService, private userRepo: UserRepositoryService) {} + + public async call(fqid: Fqid): Promise { + const response = await this.http.post(HISTORY_ENDPOINT, undefined, { fqid }); + return Object.values(response) + .flatMap(positions => getUniqueItems(positions)) + .sort((positionA, positionB) => positionB.timestamp - positionA.timestamp) + .map(position => { + return new HistoryPosition({ + ...position, + fqid, + user: this.userRepo.getViewModel(position.user_id)?.getFullName() + }); + }); + } +} diff --git a/client/src/app/gateways/presenter/presenter.ts b/client/src/app/gateways/presenter/presenter.ts index 582f8e8bd3..c5fc7e8a7a 100644 --- a/client/src/app/gateways/presenter/presenter.ts +++ b/client/src/app/gateways/presenter/presenter.ts @@ -5,5 +5,6 @@ export enum Presenter { GET_USER_RELATED_MODELS = `get_user_related_models`, GET_USER_SCOPE = `get_user_scope`, GET_FORWARDING_MEETINGS = `get_forwarding_meetings`, - SEARCH_USERS_BY_NAME_OR_EMAIL = `search_users_by_name_or_email` + SEARCH_USERS_BY_NAME_OR_EMAIL = `search_users_by_name_or_email`, + GET_HISTORY_INFORMATION = `get_history_information` } diff --git a/client/src/app/infrastructure/utils/index.ts b/client/src/app/infrastructure/utils/index.ts index 32250bf981..6c60df324a 100644 --- a/client/src/app/infrastructure/utils/index.ts +++ b/client/src/app/infrastructure/utils/index.ts @@ -1,3 +1,4 @@ export * from './functions'; export * from './nullable-partial'; export * from './functionable'; +export * from './lang-to-locale'; diff --git a/client/src/app/infrastructure/utils/lang-to-locale.ts b/client/src/app/infrastructure/utils/lang-to-locale.ts new file mode 100644 index 0000000000..0c8a4146a2 --- /dev/null +++ b/client/src/app/infrastructure/utils/lang-to-locale.ts @@ -0,0 +1,23 @@ +/** + * Helper function to convert a language indicator (en, de) + * to a locale indicator (de-DE, en-US) + * + * Necessary to correctly format timestamps + */ +export function langToLocale(lang: string): string { + switch (lang) { + case `en`: { + return `en-GB`; + } + case `de`: { + return `de-DE`; + } + case `cz`: { + return `cs-CZ`; + } + default: { + // has YYYY-MM-DD HH:mm:SS + return `lt-LT`; + } + } +} diff --git a/client/src/app/openslides-main-module/services/app-load.service.ts b/client/src/app/openslides-main-module/services/app-load.service.ts index 5274c44d76..48efb22206 100644 --- a/client/src/app/openslides-main-module/services/app-load.service.ts +++ b/client/src/app/openslides-main-module/services/app-load.service.ts @@ -23,6 +23,7 @@ import { ChatAppConfig } from 'src/app/site/pages/meetings/pages/chat/chat.confi import { PollsAppConfig } from 'src/app/site/pages/meetings/pages/polls/polls.config'; import { HomeAppConfig } from 'src/app/site/pages/meetings/pages/home/home.config'; import { MeetingSettingsAppConfig } from 'src/app/site/pages/meetings/pages/meeting-settings/meeting-settings.config'; +import { HistoryAppConfig } from 'src/app/site/pages/meetings/pages/history/history.config'; const servicesOnAppsLoaded: Type[] = [ModelRequestBuilderService]; @@ -39,6 +40,7 @@ const appConfigs: AppConfig[] = [ AgendaAppConfig, AssignmentsAppConfig, MotionsAppConfig, + HistoryAppConfig, ParticipantsAppConfig, PollsAppConfig, MediafileAppConfig, diff --git a/client/src/app/site/modules/site-wrapper/components/banner/banner.component.html b/client/src/app/site/modules/site-wrapper/components/banner/banner.component.html new file mode 100644 index 0000000000..0d9d282258 --- /dev/null +++ b/client/src/app/site/modules/site-wrapper/components/banner/banner.component.html @@ -0,0 +1,18 @@ + diff --git a/client/src/app/site/modules/site-wrapper/components/banner/banner.component.scss b/client/src/app/site/modules/site-wrapper/components/banner/banner.component.scss new file mode 100644 index 0000000000..64acf216b2 --- /dev/null +++ b/client/src/app/site/modules/site-wrapper/components/banner/banner.component.scss @@ -0,0 +1,38 @@ +@import 'src/assets/styles/media-queries.scss'; + +.banner { + &.larger-on-mobile { + @include set-breakpoint-lower(sm) { + min-height: 40px; + } + } + + position: relative; // was fixed before to prevent the overflow + min-height: 20px; + line-height: 20px; + width: 100%; + text-align: center; + border-bottom: 1px solid white; + + a { + align-items: center; + justify-content: center; + text-decoration: none; + color: white; + &.banner-link { + width: 100%; + height: 100%; + } + } + + mat-icon { + $font-size: 16px; + width: $font-size; + height: $font-size; + font-size: $font-size; + + & + span { + margin-left: 10px; + } + } +} diff --git a/client/src/app/site/modules/site-wrapper/components/banner/banner.component.spec.ts b/client/src/app/site/modules/site-wrapper/components/banner/banner.component.spec.ts new file mode 100644 index 0000000000..775af91a71 --- /dev/null +++ b/client/src/app/site/modules/site-wrapper/components/banner/banner.component.spec.ts @@ -0,0 +1,24 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { BannerComponent } from './banner.component'; + +describe('BannerComponent', () => { + let component: BannerComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [BannerComponent] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(BannerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/modules/site-wrapper/components/banner/banner.component.ts b/client/src/app/site/modules/site-wrapper/components/banner/banner.component.ts new file mode 100644 index 0000000000..7a7707ae47 --- /dev/null +++ b/client/src/app/site/modules/site-wrapper/components/banner/banner.component.ts @@ -0,0 +1,22 @@ +import { Component, OnInit } from '@angular/core'; +import { Constructable } from 'src/app/domain/interfaces/constructable'; +import { ComponentPortal } from '@angular/cdk/portal'; +import { Observable } from 'rxjs'; +import { BannerDefinition, BannerService } from '../../services/banner.service'; + +@Component({ + selector: 'os-banner', + templateUrl: './banner.component.html', + styleUrls: ['./banner.component.scss'] +}) +export class BannerComponent { + public readonly activeBanners: Observable; + + public constructor(bannerService: BannerService) { + this.activeBanners = bannerService.getActiveBannersObservable(); + } + + public createComponentPortal(component: Constructable): ComponentPortal { + return new ComponentPortal(component); + } +} diff --git a/client/src/app/site/modules/site-wrapper/components/site-wrapper/site-wrapper.component.html b/client/src/app/site/modules/site-wrapper/components/site-wrapper/site-wrapper.component.html new file mode 100644 index 0000000000..6b383ad676 --- /dev/null +++ b/client/src/app/site/modules/site-wrapper/components/site-wrapper/site-wrapper.component.html @@ -0,0 +1,2 @@ + + diff --git a/client/src/app/site/modules/site-wrapper/components/site-wrapper/site-wrapper.component.scss b/client/src/app/site/modules/site-wrapper/components/site-wrapper/site-wrapper.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/client/src/app/site/modules/site-wrapper/components/site-wrapper/site-wrapper.component.spec.ts b/client/src/app/site/modules/site-wrapper/components/site-wrapper/site-wrapper.component.spec.ts new file mode 100644 index 0000000000..866730399d --- /dev/null +++ b/client/src/app/site/modules/site-wrapper/components/site-wrapper/site-wrapper.component.spec.ts @@ -0,0 +1,24 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SiteWrapperComponent } from './site-wrapper.component'; + +describe('SiteWrapperComponent', () => { + let component: SiteWrapperComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [SiteWrapperComponent] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(SiteWrapperComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/modules/site-wrapper/components/site-wrapper/site-wrapper.component.ts b/client/src/app/site/modules/site-wrapper/components/site-wrapper/site-wrapper.component.ts new file mode 100644 index 0000000000..660ce2dcb7 --- /dev/null +++ b/client/src/app/site/modules/site-wrapper/components/site-wrapper/site-wrapper.component.ts @@ -0,0 +1,11 @@ +import { Component, OnInit } from '@angular/core'; +import { ThemeService } from 'src/app/site/services/theme.service'; + +@Component({ + selector: 'os-site-wrapper', + templateUrl: './site-wrapper.component.html', + styleUrls: ['./site-wrapper.component.scss'] +}) +export class SiteWrapperComponent { + public constructor(_themeService: ThemeService) {} +} diff --git a/client/src/app/site/services/banner.service/banner.service.spec.ts b/client/src/app/site/modules/site-wrapper/services/banner.service.spec.ts similarity index 100% rename from client/src/app/site/services/banner.service/banner.service.spec.ts rename to client/src/app/site/modules/site-wrapper/services/banner.service.spec.ts diff --git a/client/src/app/site/modules/site-wrapper/services/banner.service.ts b/client/src/app/site/modules/site-wrapper/services/banner.service.ts new file mode 100644 index 0000000000..41389765ea --- /dev/null +++ b/client/src/app/site/modules/site-wrapper/services/banner.service.ts @@ -0,0 +1,72 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { Constructable } from 'src/app/domain/interfaces/constructable'; + +export class BannerDefinition { + type?: string; + class?: string; + icon?: string; + text?: string; + subText?: string; + link?: string; + largerOnMobileView?: boolean; + component?: Constructable; +} + +@Injectable({ + providedIn: 'root' +}) +export class BannerService { + private get currentBanners(): BannerDefinition[] { + return this._activeBanners.value; + } + + private readonly _activeBanners: BehaviorSubject = new BehaviorSubject([]); + + public getActiveBannersObservable(): Observable { + return this._activeBanners.asObservable(); + } + + /** + * Adds a banner to the list of active banners. Skip the banner if it's already in the list + * @param toAdd the banner to add + */ + public addBanner(toAdd: BannerDefinition): void { + if (!this.currentBanners.find(banner => JSON.stringify(banner) === JSON.stringify(toAdd))) { + this._activeBanners.next(this.currentBanners.concat([toAdd])); + } + } + + /** + * Replaces a banner with another. Convenience method to prevent flickering + * @param toAdd the banner to add + * @param toRemove the banner to remove + */ + public replaceBanner(toRemove: BannerDefinition, toAdd: BannerDefinition): void { + if (toRemove) { + const newArray = Array.from(this.currentBanners); + const idx = newArray.findIndex(banner => banner === toRemove); + if (idx === -1) { + throw new Error(`The given banner couldn't be found.`); + } else { + newArray[idx] = toAdd; + this._activeBanners.next(newArray); // no need for this.update since the length doesn't change + } + } else { + this.addBanner(toAdd); + } + } + + /** + * removes the given banner + * @param toRemove the banner to remove + */ + public removeBanner(toRemove: BannerDefinition): void { + if (toRemove) { + const newBanners = this.currentBanners.filter( + banner => JSON.stringify(banner) !== JSON.stringify(toRemove) + ); + this._activeBanners.next(newBanners); + } + } +} diff --git a/client/src/app/site/modules/site-wrapper/site-wrapper.module.ts b/client/src/app/site/modules/site-wrapper/site-wrapper.module.ts new file mode 100644 index 0000000000..a5a8be9949 --- /dev/null +++ b/client/src/app/site/modules/site-wrapper/site-wrapper.module.ts @@ -0,0 +1,14 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SiteWrapperComponent } from './components/site-wrapper/site-wrapper.component'; +import { RouterModule } from '@angular/router'; +import { BannerComponent } from './components/banner/banner.component'; +import { MatIconModule } from '@angular/material/icon'; +import { OpenSlidesTranslationModule } from 'src/app/site/modules/translations'; +import { PortalModule } from '@angular/cdk/portal'; + +@NgModule({ + declarations: [SiteWrapperComponent, BannerComponent], + imports: [CommonModule, RouterModule, PortalModule, MatIconModule, OpenSlidesTranslationModule.forChild()] +}) +export class SiteWrapperModule {} diff --git a/client/src/app/site/pages/meetings/meetings-routing.module.ts b/client/src/app/site/pages/meetings/meetings-routing.module.ts index ebea3d7b9c..bf8a2fd695 100644 --- a/client/src/app/site/pages/meetings/meetings-routing.module.ts +++ b/client/src/app/site/pages/meetings/meetings-routing.module.ts @@ -74,6 +74,11 @@ const routes: Routes = [ path: `chat`, loadChildren: () => import(`./pages/chat/chat.module`).then(m => m.ChatModule), canLoad: [PermissionGuard] + }, + { + path: `history`, + loadChildren: () => import(`./pages/history/history.module`).then(m => m.HistoryModule), + canLoad: [PermissionGuard] } ] } diff --git a/client/src/app/site/pages/meetings/modules/meetings-navigation/meetings-navigation.module.ts b/client/src/app/site/pages/meetings/modules/meetings-navigation/meetings-navigation.module.ts index df4cd35cb4..a82fbe6d2d 100644 --- a/client/src/app/site/pages/meetings/modules/meetings-navigation/meetings-navigation.module.ts +++ b/client/src/app/site/pages/meetings/modules/meetings-navigation/meetings-navigation.module.ts @@ -11,11 +11,11 @@ import { InteractionModule } from '../../pages/interaction/interaction.module'; import { MatBadgeModule } from '@angular/material/badge'; import { DirectivesModule } from 'src/app/ui/directives'; -const DECLARATONS = [MeetingsNavigationWrapperComponent]; +const EXPORTS = [MeetingsNavigationWrapperComponent]; @NgModule({ - declarations: DECLARATONS, - exports: DECLARATONS, + declarations: [...EXPORTS], + exports: EXPORTS, imports: [ CommonModule, SidenavModule, diff --git a/client/src/app/site/pages/meetings/pages/agenda/components/agenda-main/agenda-main.component.ts b/client/src/app/site/pages/meetings/pages/agenda/components/agenda-main/agenda-main.component.ts index 7776d66565..99d67083b5 100644 --- a/client/src/app/site/pages/meetings/pages/agenda/components/agenda-main/agenda-main.component.ts +++ b/client/src/app/site/pages/meetings/pages/agenda/components/agenda-main/agenda-main.component.ts @@ -3,7 +3,10 @@ import { BaseModelRequestHandlerComponent } from 'src/app/site/base/base-model-r import { ViewMeeting } from 'src/app/site/pages/meetings/view-models/view-meeting'; import { map } from 'rxjs'; import { getAgendaSubscriptionConfig, getTopicSubscriptionConfig } from '../../config/model-subscription'; -import { getMotionSubscriptionConfig } from '../../../motions/config/model-subscription'; +import { + getMotionListSubscriptionConfig, + getMotionBlockSubscriptionConfig +} from '../../../motions/config/model-subscription'; import { getAssignmentSubscriptionConfig } from '../../../assignments/config/model-subscription'; @Component({ @@ -17,7 +20,8 @@ export class AgendaMainComponent extends BaseModelRequestHandlerComponent { this.subscribeTo( getAgendaSubscriptionConfig(id, () => this.getNextMeetingIdObservable()), getTopicSubscriptionConfig(id, () => this.getNextMeetingIdObservable()), - getMotionSubscriptionConfig(id, () => this.getNextMeetingIdObservable()), + getMotionListSubscriptionConfig(id, () => this.getNextMeetingIdObservable()), + getMotionBlockSubscriptionConfig(id, () => this.getNextMeetingIdObservable()), getAssignmentSubscriptionConfig(id, () => this.getNextMeetingIdObservable()) ); } diff --git a/client/src/app/site/pages/meetings/pages/history/components/history-banner/history-banner.component.html b/client/src/app/site/pages/meetings/pages/history/components/history-banner/history-banner.component.html new file mode 100644 index 0000000000..eb876f7fdd --- /dev/null +++ b/client/src/app/site/pages/meetings/pages/history/components/history-banner/history-banner.component.html @@ -0,0 +1,7 @@ +
+ + {{ 'You are using the history mode of OpenSlides. Changes will not be saved.' | translate }} + + + {{ 'Exit' | translate }} +
diff --git a/client/src/app/site/pages/meetings/pages/history/components/history-banner/history-banner.component.scss b/client/src/app/site/pages/meetings/pages/history/components/history-banner/history-banner.component.scss new file mode 100644 index 0000000000..571d02760b --- /dev/null +++ b/client/src/app/site/pages/meetings/pages/history/components/history-banner/history-banner.component.scss @@ -0,0 +1,19 @@ +:host { + width: 100%; +} + +.history-banner { + background: repeating-linear-gradient(45deg, #fe0, #fe0 10px, #070600 0, #000 20px); + + span, + a { + padding: 2px; + color: #000000; + background: #ffee00; + } + + a { + cursor: pointer; + font-weight: bold; + } +} diff --git a/client/src/app/site/pages/meetings/pages/history/components/history-banner/history-banner.component.spec.ts b/client/src/app/site/pages/meetings/pages/history/components/history-banner/history-banner.component.spec.ts new file mode 100644 index 0000000000..2e5c007a5c --- /dev/null +++ b/client/src/app/site/pages/meetings/pages/history/components/history-banner/history-banner.component.spec.ts @@ -0,0 +1,24 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { HistoryBannerComponent } from './history-banner.component'; + +describe('HistoryBannerComponent', () => { + let component: HistoryBannerComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [HistoryBannerComponent] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(HistoryBannerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/pages/meetings/pages/history/components/history-banner/history-banner.component.ts b/client/src/app/site/pages/meetings/pages/history/components/history-banner/history-banner.component.ts new file mode 100644 index 0000000000..5621cf9fde --- /dev/null +++ b/client/src/app/site/pages/meetings/pages/history/components/history-banner/history-banner.component.ts @@ -0,0 +1,15 @@ +import { Component, OnInit } from '@angular/core'; +import { HistoryService } from '../../services/history.service'; + +@Component({ + selector: 'os-history-banner', + templateUrl: './history-banner.component.html', + styleUrls: ['./history-banner.component.scss'] +}) +export class HistoryBannerComponent { + public constructor(private historyService: HistoryService) {} + + public leaveHistoryMode(): void { + this.historyService.leaveHistoryMode(); + } +} diff --git a/client/src/app/site/pages/meetings/pages/history/components/history-list/history-list.component.html b/client/src/app/site/pages/meetings/pages/history/components/history-list/history-list.component.html new file mode 100644 index 0000000000..001b85a53a --- /dev/null +++ b/client/src/app/site/pages/meetings/pages/history/components/history-list/history-list.component.html @@ -0,0 +1,64 @@ + + +
{{ 'History' | translate }}
+
+ + +
+
+ + + + + + + + +
+
+ + + search + +
+
+ + + + + {{ 'Timestamp' | translate }} + {{ getTimestamp(position) }} + + + + + {{ 'Comment' | translate }} + +
+
+ {{ description | translate }} +
+
+
+
+ + + + {{ 'Changed by' | translate }} + {{ position.user }} + + + + +
+ + +
diff --git a/client/src/app/site/pages/meetings/pages/history/components/history-list/history-list.component.scss b/client/src/app/site/pages/meetings/pages/history/components/history-list/history-list.component.scss new file mode 100644 index 0000000000..5fb8b30973 --- /dev/null +++ b/client/src/app/site/pages/meetings/pages/history/components/history-list/history-list.component.scss @@ -0,0 +1,16 @@ +.no-info { + font-style: italic; +} + +.history-table-header { + display: flex; + justify-content: space-between; +} + +.os-headed-listview-table { + .mat-header-cell, + .mat-cell { + padding-left: 5px; + padding-right: 5px; + } +} diff --git a/client/src/app/site/pages/meetings/pages/history/components/history-list/history-list.component.spec.ts b/client/src/app/site/pages/meetings/pages/history/components/history-list/history-list.component.spec.ts new file mode 100644 index 0000000000..59e9cd53ad --- /dev/null +++ b/client/src/app/site/pages/meetings/pages/history/components/history-list/history-list.component.spec.ts @@ -0,0 +1,26 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { E2EImportsModule } from 'e2e-imports.module'; + +import { HistoryListComponent } from './history-list.component'; + +describe(`HistoryListComponent`, () => { + let component: HistoryListComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [HistoryListComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(HistoryListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it(`should create`, () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/pages/meetings/pages/history/components/history-list/history-list.component.ts b/client/src/app/site/pages/meetings/pages/history/components/history-list/history-list.component.ts new file mode 100644 index 0000000000..6e93bf469b --- /dev/null +++ b/client/src/app/site/pages/meetings/pages/history/components/history-list/history-list.component.ts @@ -0,0 +1,225 @@ +import { Component, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { MatTableDataSource } from '@angular/material/table'; +import { ActivatedRoute, Router } from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; +import { Observable, Subject } from 'rxjs'; +import { Motion } from 'src/app/domain/models/motions/motion'; +import { Position } from '../../definitions'; +import { ViewMotion } from 'src/app/site/pages/meetings/pages/motions'; +import { ViewModelStoreService } from 'src/app/site/services/view-model-store.service'; +import { Fqid, Id } from 'src/app/domain/definitions/key-types'; +import { + fqidFromCollectionAndId, + idFromFqid, + collectionIdFromFqid +} from 'src/app/infrastructure/utils/transform-functions'; +import { MotionRepositoryService } from 'src/app/gateways/repositories/motions'; +import { langToLocale } from 'src/app/infrastructure/utils/lang-to-locale'; +import { HistoryPosition, HistoryPresenterService } from 'src/app/gateways/presenter/history-presenter.service'; +import { isDetailNavigable } from 'src/app/domain/interfaces/detail-navigable'; +import { OperatorService } from 'src/app/site/services/operator.service'; +import { HistoryService } from '../../services/history.service'; +import { BaseMeetingComponent } from 'src/app/site/pages/meetings/base/base-meeting.component'; +import { MeetingComponentServiceCollectorService } from 'src/app/site/pages/meetings/services/meeting-component-service-collector.service'; + +const COLLECTION = Motion.COLLECTION; + +/** + * A list view for the history. + * + * Should display all changes that have been made in OpenSlides. + */ +@Component({ + selector: `os-history-list`, + templateUrl: `./history-list.component.html`, + styleUrls: [`./history-list.component.scss`] +}) +export class HistoryListComponent extends BaseMeetingComponent implements OnInit { + /** + * Subject determine when the custom timestamp subject changes + */ + public customTimestampChanged: Subject = new Subject(); + + public dataSource: MatTableDataSource = new MatTableDataSource(); + + public pageSizes = [50, 100, 150, 200, 250]; + + /** + * The form for the selection of the motion + * When more models are supported, add a "collection"-dropdown + */ + public motionSelectForm: FormGroup; + + /** + * The observer for the all motions + */ + public motions: Observable; + + public get currentMotionId(): number | null { + return this.motionSelectForm.controls['motion'].value; + } + + private _fqid: Fqid | null = null; + + public constructor( + componentServiceCollector: MeetingComponentServiceCollectorService, + translate: TranslateService, + private viewModelStore: ViewModelStoreService, + private formBuilder: FormBuilder, + private motionRepo: MotionRepositoryService, + private activatedRoute: ActivatedRoute, + private presenter: HistoryPresenterService, + private operator: OperatorService, + private historyService: HistoryService + ) { + super(componentServiceCollector, translate); + + this.motionSelectForm = this.formBuilder.group({ + motion: [] + }); + this.motions = this.motionRepo.getViewModelListObservable(); + + this.motionSelectForm.controls['motion'].valueChanges.subscribe((id: number) => { + console.log(`id`, id); + if (!id || (Array.isArray(id) && !id.length)) { + return; + } + const fqid = fqidFromCollectionAndId(COLLECTION, id); + this.queryByFqid(fqid); + + // Update the URL. + this.router.navigate([], { + relativeTo: this.activatedRoute, + queryParams: { fqid }, + replaceUrl: true + }); + }); + } + + /** + * Init function for the history list. + */ + public ngOnInit(): void { + super.setTitle(`History`); + + this.dataSource.filterPredicate = (position: HistoryPosition, filter: string) => { + filter = filter ? filter.toLowerCase() : ``; + + if (!position) { + return false; + } + if (position.user.toLowerCase().indexOf(filter) >= 0) { + return true; + } + + if (this.currentMotionId) { + const motion = this.viewModelStore.get(COLLECTION, this.currentMotionId); + if (motion && motion.getTitle().toLowerCase().indexOf(filter) >= 0) { + return true; + } + } + + return this.parseInformation(position).toLowerCase().indexOf(filter) >= 0; + }; + + // If an element id is given, validate it and update the view. + this.loadFromParams(); + } + + private loadFromParams(): void { + const fqid = this.activatedRoute.snapshot.queryParams?.['fqid']; + if (!fqid) { + return; + } + + let id: Id; + try { + id = idFromFqid(fqid); + } catch { + return; + } + if (!id) { + return; + } + this.queryByFqid(fqid); + this.motionSelectForm.patchValue( + { + motion: id + }, + { emitEvent: false } + ); + } + + /** + * Sets the data source to the requested element id. + */ + private async queryByFqid(fqid: Fqid): Promise { + this._fqid = fqid; + try { + const response = await this.presenter.call(fqid); + this.dataSource.data = response; + } catch (e) { + this.raiseError(e); + } + } + + /** + * Returns the row definition for the table + * + * @returns an array of strings that contains the required row definition + */ + public getRowDef(): string[] { + return [`time`, `info`, `user`]; + } + + /** + * Click handler for rows in the history table. + * Serves as an entry point for the time travel routine + */ + public async onClickRow(position: Position): Promise { + console.log(`click on row`, position, this.operator.isInGroupIds(this.activeMeeting.admin_group_id)); + if (!this.operator.isInGroupIds(this.activeMeeting.admin_group_id)) { + return; + } + + await this.historyService.enterHistoryMode(this._fqid, position); + const [collection, id] = collectionIdFromFqid(this._fqid); + const element = this.viewModelStore.get(collection, id); + console.log(`go to element:`, element); + if (element && isDetailNavigable(element)) { + this.router.navigate([element.getDetailStateUrl()]); + } else { + const message = this.translate.instant('Cannot navigate to the selected history element.'); + this.raiseError(message); + } + } + + public getTimestamp(position: Position): string { + return position.getLocaleString(langToLocale(this.translate.currentLang)); + } + + public refresh(): void { + if (this.currentMotionId) { + this.queryByFqid(fqidFromCollectionAndId(COLLECTION, this.currentMotionId)); + } + } + + /** + * Returns a translated history information string which contains optional (translated) arguments. + */ + public parseInformation(position: HistoryPosition): string { + return Object.keys(position.information) + .map(key => `${key}: ${position.information[key].join(`, `)}`) + .join(`; `); + } + + /** + * Handles the search fields' inputs + * + * @param keyTarget: a filter string. Matching is case-insensitive + */ + public applySearch(keyTarget: EventTarget): void { + this.dataSource.filter = (keyTarget).value; + } +} diff --git a/client/src/app/site/pages/meetings/pages/history/components/history-main/history-main.component.html b/client/src/app/site/pages/meetings/pages/history/components/history-main/history-main.component.html new file mode 100644 index 0000000000..0680b43f9c --- /dev/null +++ b/client/src/app/site/pages/meetings/pages/history/components/history-main/history-main.component.html @@ -0,0 +1 @@ + diff --git a/client/src/app/site/pages/meetings/pages/history/components/history-main/history-main.component.scss b/client/src/app/site/pages/meetings/pages/history/components/history-main/history-main.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/client/src/app/site/pages/meetings/pages/history/components/history-main/history-main.component.spec.ts b/client/src/app/site/pages/meetings/pages/history/components/history-main/history-main.component.spec.ts new file mode 100644 index 0000000000..9dc7838a4f --- /dev/null +++ b/client/src/app/site/pages/meetings/pages/history/components/history-main/history-main.component.spec.ts @@ -0,0 +1,24 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { HistoryMainComponent } from './history-main.component'; + +describe('HistoryMainComponent', () => { + let component: HistoryMainComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [HistoryMainComponent] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(HistoryMainComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/pages/meetings/pages/history/components/history-main/history-main.component.ts b/client/src/app/site/pages/meetings/pages/history/components/history-main/history-main.component.ts new file mode 100644 index 0000000000..8b5efd8014 --- /dev/null +++ b/client/src/app/site/pages/meetings/pages/history/components/history-main/history-main.component.ts @@ -0,0 +1,18 @@ +import { Component, OnInit } from '@angular/core'; +import { BaseModelRequestHandlerComponent } from 'src/app/site/base/base-model-request-handler.component'; +import { getMotionListSubscriptionConfig } from '../../../motions/config/model-subscription'; + +@Component({ + selector: 'os-history-main', + templateUrl: './history-main.component.html', + styleUrls: ['./history-main.component.scss'] +}) +export class HistoryMainComponent extends BaseModelRequestHandlerComponent { + protected override onParamsChanged(params: any): void { + if (params[`meetingId`]) { + this.subscribeTo( + getMotionListSubscriptionConfig(+params[`meetingId`], () => this.getNextMeetingIdObservable()) + ); + } + } +} diff --git a/client/src/app/site/pages/meetings/pages/history/definitions/index.ts b/client/src/app/site/pages/meetings/pages/history/definitions/index.ts new file mode 100644 index 0000000000..aa2a31e774 --- /dev/null +++ b/client/src/app/site/pages/meetings/pages/history/definitions/index.ts @@ -0,0 +1 @@ +export * from './position'; diff --git a/client/src/app/site/pages/meetings/pages/history/definitions/position.ts b/client/src/app/site/pages/meetings/pages/history/definitions/position.ts new file mode 100644 index 0000000000..47e199ddfa --- /dev/null +++ b/client/src/app/site/pages/meetings/pages/history/definitions/position.ts @@ -0,0 +1,26 @@ +export class Position { + public position: number; + public timestamp: number; + public information: string[]; + public user: string; + + public get date(): Date { + return new Date(this.timestamp * 1000); + } + + public constructor(input: Position) { + if (input) { + Object.assign(this, input); + } + } + + /** + * Converts the date (this.now) to a time and date string. + * + * @param locale locale indicator, i.e 'de-DE' + * @returns a human readable kind of time and date representation + */ + public getLocaleString(locale: string): string { + return this.date.toLocaleString(locale); + } +} diff --git a/client/src/app/site/pages/meetings/pages/history/history-routing.module.ts b/client/src/app/site/pages/meetings/pages/history/history-routing.module.ts new file mode 100644 index 0000000000..4c437b3e94 --- /dev/null +++ b/client/src/app/site/pages/meetings/pages/history/history-routing.module.ts @@ -0,0 +1,25 @@ +import { NgModule } from '@angular/core'; +import { Route, RouterModule } from '@angular/router'; + +import { HistoryListComponent } from './components/history-list/history-list.component'; +import { HistoryMainComponent } from './components/history-main/history-main.component'; + +/** + * Define the routes for the history module + */ +const routes: Route[] = [ + { + path: ``, + component: HistoryMainComponent, + children: [{ path: ``, pathMatch: `full`, component: HistoryListComponent }] + } +]; + +/** + * Define the routing component and setup the routes + */ +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class HistoryRoutingModule {} diff --git a/client/src/app/site/pages/meetings/pages/history/history.config.ts b/client/src/app/site/pages/meetings/pages/history/history.config.ts new file mode 100644 index 0000000000..e8da43291c --- /dev/null +++ b/client/src/app/site/pages/meetings/pages/history/history.config.ts @@ -0,0 +1,20 @@ +import { Permission } from 'src/app/domain/definitions/permission'; +import { AppConfig } from 'src/app/infrastructure/definitions/app-config'; + +/** + * Config object for history. + * Hooks into the navigation. + */ +export const HistoryAppConfig: AppConfig = { + name: `history`, + meetingMenuMentries: [ + { + route: `history`, + displayName: `History`, + icon: `history`, + weight: 1200, + permission: Permission.meetingCanSeeHistory, + hasDividerBelow: true + } + ] +}; diff --git a/client/src/app/site/pages/meetings/pages/history/history.module.ts b/client/src/app/site/pages/meetings/pages/history/history.module.ts new file mode 100644 index 0000000000..06d38a880c --- /dev/null +++ b/client/src/app/site/pages/meetings/pages/history/history.module.ts @@ -0,0 +1,40 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; + +import { HistoryListComponent } from './components/history-list/history-list.component'; +import { HistoryRoutingModule } from './history-routing.module'; +import { MatCardModule } from '@angular/material/card'; +import { MatTableModule } from '@angular/material/table'; +import { OpenSlidesTranslationModule } from 'src/app/site/modules/translations'; +import { HeadBarModule } from 'src/app/ui/modules/head-bar'; +import { MatPaginatorModule } from '@angular/material/paginator'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { SearchSelectorModule } from 'src/app/ui/modules/search-selector'; +import { ReactiveFormsModule } from '@angular/forms'; +import { HistoryMainComponent } from './components/history-main/history-main.component'; +import { HistoryBannerComponent } from './components/history-banner/history-banner.component'; + +/** + * App module for the history feature. + * Declares the used components. + */ +@NgModule({ + imports: [ + CommonModule, + HistoryRoutingModule, + MatCardModule, + MatTableModule, + MatPaginatorModule, + MatIconModule, + MatInputModule, + MatFormFieldModule, + ReactiveFormsModule, + OpenSlidesTranslationModule.forChild(), + HeadBarModule, + SearchSelectorModule + ], + declarations: [HistoryListComponent, HistoryMainComponent, HistoryBannerComponent] +}) +export class HistoryModule {} diff --git a/client/src/app/site/pages/meetings/pages/history/services/history.service.spec.ts b/client/src/app/site/pages/meetings/pages/history/services/history.service.spec.ts new file mode 100644 index 0000000000..53a4f6035f --- /dev/null +++ b/client/src/app/site/pages/meetings/pages/history/services/history.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { HistoryService } from './history.service'; + +describe('HistoryService', () => { + let service: HistoryService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(HistoryService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/pages/meetings/pages/history/services/history.service.ts b/client/src/app/site/pages/meetings/pages/history/services/history.service.ts new file mode 100644 index 0000000000..de73e834da --- /dev/null +++ b/client/src/app/site/pages/meetings/pages/history/services/history.service.ts @@ -0,0 +1,77 @@ +import { Injectable } from '@angular/core'; +import { AutoupdateService } from 'src/app/site/services/autoupdate'; +import { Fqid } from 'src/app/domain/definitions/key-types'; +import { Position } from '../definitions'; +import { HistoryBannerComponent } from '../components/history-banner/history-banner.component'; +import { BannerService } from 'src/app/site/modules/site-wrapper/services/banner.service'; +import { NotifyService } from 'src/app/gateways/notify.service'; +import { ActiveMeetingIdService } from 'src/app/site/pages/meetings/services/active-meeting-id.service'; +import { OpenSlidesRouterService } from 'src/app/site/services/openslides-router.service'; +import { combineLatest } from 'rxjs'; +import { ActionService } from 'src/app/gateways/actions'; +import { MatSnackBar } from '@angular/material/snack-bar'; + +@Injectable({ + providedIn: 'root' +}) +export class HistoryService { + private _isInHistoryMode = false; + private _actionFnIndex: number | null = null; + + public constructor( + _openslidesRouter: OpenSlidesRouterService, + private autoupdateService: AutoupdateService, + private bannerService: BannerService, + private notify: NotifyService, + private activeMeetingIdService: ActiveMeetingIdService, + private actions: ActionService, + private snackBar: MatSnackBar + ) { + combineLatest([ + _openslidesRouter.beforeLeaveMeetingObservable, + _openslidesRouter.beforeSignoutObservable + ]).subscribe(() => { + this.leaveHistoryMode(); + }); + } + + public async enterHistoryMode(fqid: Fqid, historyPosition: Position): Promise { + if (!this._isInHistoryMode) { + this._isInHistoryMode = true; // Prevent going multiple times into the history mode + this.bannerService.addBanner({ component: HistoryBannerComponent }); + this.notify.disconnect(); + this.setHistoryMode(); + } + await this.loadHistoryPosition(fqid, historyPosition); + } + + public leaveHistoryMode(): void { + if (this._isInHistoryMode) { + this._isInHistoryMode = false; + this.removeActionFn(); + this.bannerService.removeBanner({ component: HistoryBannerComponent }); + this.autoupdateService.reconnect(); + this.notify.connect(this.activeMeetingIdService.meetingId!); + } + } + + private setHistoryMode(): void { + this._actionFnIndex = this.actions.addBeforeActionFn(() => { + if (this._isInHistoryMode) { + this.snackBar.open(`You cannot make changes while in history mode`, `Ok`); + } + return this._isInHistoryMode; + }); + } + + private removeActionFn(): void { + if (this._actionFnIndex) { + this.actions.removeBeforeActionFn(this._actionFnIndex); + this._actionFnIndex = null; + } + } + + private async loadHistoryPosition(fqid: Fqid, historyPosition: Position): Promise { + this.autoupdateService.reconnect({ position: historyPosition.position }); + } +} diff --git a/client/src/app/site/pages/meetings/pages/motions/components/motion-main/motion-main.component.ts b/client/src/app/site/pages/meetings/pages/motions/components/motion-main/motion-main.component.ts index a43054e0c0..0d95d08cee 100644 --- a/client/src/app/site/pages/meetings/pages/motions/components/motion-main/motion-main.component.ts +++ b/client/src/app/site/pages/meetings/pages/motions/components/motion-main/motion-main.component.ts @@ -4,9 +4,12 @@ import { BaseModelRequestHandlerComponent } from 'src/app/site/base/base-model-r import { ViewMeeting } from 'src/app/site/pages/meetings/view-models/view-meeting'; import { getParticipantSubscriptionConfig } from '../../../participants/config/model-subscription'; import { getAgendaSubscriptionConfig } from '../../../agenda/config/model-subscription'; -import { getMotionSubscriptionConfig } from '../../config/model-subscription'; - -const MOTION_LIST_SUBSCRIPTION = `motion_list`; +import { + getMotionBlockSubscriptionConfig, + getMotionListSubscriptionConfig, + getMotionWorkflowSubscriptionConfig, + getMotionsSubmodelSubscriptionConfig +} from '../../config/model-subscription'; @Component({ selector: 'os-motion-main', @@ -17,7 +20,10 @@ export class MotionMainComponent extends BaseModelRequestHandlerComponent { protected override onNextMeetingId(id: number | null): void { if (id) { this.subscribeTo( - getMotionSubscriptionConfig(id, () => this.getNextMeetingIdObservable()), + getMotionListSubscriptionConfig(id, () => this.getNextMeetingIdObservable()), + getMotionBlockSubscriptionConfig(id, () => this.getNextMeetingIdObservable()), + getMotionWorkflowSubscriptionConfig(id, () => this.getNextMeetingIdObservable()), + getMotionsSubmodelSubscriptionConfig(id, () => this.getNextMeetingIdObservable()), getParticipantSubscriptionConfig(id, () => this.getNextMeetingIdObservable()), getAgendaSubscriptionConfig(id, () => this.getNextMeetingIdObservable()) ); diff --git a/client/src/app/site/pages/meetings/pages/motions/config/model-subscription.ts b/client/src/app/site/pages/meetings/pages/motions/config/model-subscription.ts index 23f50b2627..9ff4360761 100644 --- a/client/src/app/site/pages/meetings/pages/motions/config/model-subscription.ts +++ b/client/src/app/site/pages/meetings/pages/motions/config/model-subscription.ts @@ -3,14 +3,51 @@ import { Observable, map } from 'rxjs'; import { ViewMeeting } from 'src/app/site/pages/meetings/view-models/view-meeting'; const MOTION_LIST_SUBSCRIPTION = `motion_list`; +const MOTION_BLOCK_SUBSCRIPTION = `motion_block`; +const MOTION_WORKFLOW_SUBSCRiPTION = `motion_workflow`; +const MOTION_SUBMODELS_SUBSCRIPTION = `motion_submodels`; -export const getMotionSubscriptionConfig = (id: Id, getNextMeetingIdObservable: () => Observable) => ({ +export const getMotionListSubscriptionConfig = (id: Id, getNextMeetingIdObservable: () => Observable) => ({ + modelRequest: { + viewModelCtor: ViewMeeting, + ids: [id], + follow: [`motion_ids`] + }, + subscriptionName: MOTION_LIST_SUBSCRIPTION, + hideWhen: getNextMeetingIdObservable().pipe(map(id => !id)) +}); + +export const getMotionBlockSubscriptionConfig = (id: Id, getNextMeetingIdObservable: () => Observable) => ({ + modelRequest: { + viewModelCtor: ViewMeeting, + ids: [id], + follow: [`motion_block_ids`] + }, + subscriptionName: MOTION_BLOCK_SUBSCRIPTION, + hideWhen: getNextMeetingIdObservable().pipe(map(id => !id)) +}); + +export const getMotionWorkflowSubscriptionConfig = ( + id: Id, + getNextMeetingIdObservable: () => Observable +) => ({ + modelRequest: { + viewModelCtor: ViewMeeting, + ids: [id], + follow: [`motion_workflow_ids`] + }, + subscriptionName: MOTION_WORKFLOW_SUBSCRiPTION, + hideWhen: getNextMeetingIdObservable().pipe(map(id => !id)) +}); + +export const getMotionsSubmodelSubscriptionConfig = ( + id: Id, + getNextMeetingIdObservable: () => Observable +) => ({ modelRequest: { viewModelCtor: ViewMeeting, ids: [id], follow: [ - `motion_ids`, - `motion_block_ids`, `motion_category_ids`, `motion_workflow_ids`, `motion_state_ids`, @@ -22,6 +59,6 @@ export const getMotionSubscriptionConfig = (id: Id, getNextMeetingIdObservable: `personal_note_ids` ] }, - subscriptionName: MOTION_LIST_SUBSCRIPTION, + subscriptionName: MOTION_SUBMODELS_SUBSCRIPTION, hideWhen: getNextMeetingIdObservable().pipe(map(id => !id)) }); 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 index e8c28a9b38..a8b98bbb08 100644 --- 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 @@ -90,6 +90,13 @@

{{ 'New amendment' | translate }}

{{ 'Remove from agenda' | translate }} + +
+ +