From bd9c0a21e04d87e80acd9b5e675b8755db7bc3b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adina=20=C8=9Aeudan?= Date: Mon, 28 Mar 2022 21:29:23 +0300 Subject: [PATCH 1/2] Notifications & digital signature improvements Handle archived & deleted dossiers in notifications --- .../notifications.component.html | 6 +- .../notifications.component.scss | 2 + .../notifications/notifications.component.ts | 55 ++++------- .../account-side-nav.component.ts | 2 +- .../digital-signature-screen.component.ts | 95 ++++++++++--------- .../src/app/services/notifications.service.ts | 87 +++++++++-------- apps/red-ui/src/assets/i18n/de.json | 27 +++--- apps/red-ui/src/assets/i18n/en.json | 27 +++--- 8 files changed, 153 insertions(+), 148 deletions(-) diff --git a/apps/red-ui/src/app/components/notifications/notifications.component.html b/apps/red-ui/src/app/components/notifications/notifications.component.html index c1d447aab..a744fdf0d 100644 --- a/apps/red-ui/src/app/components/notifications/notifications.component.html +++ b/apps/red-ui/src/app/components/notifications/notifications.component.html @@ -13,11 +13,11 @@
{{ group.date }}
-
View all
+
View all
{{ notification.time }}
; - hasUnreadNotifications$: Observable; - groupedNotifications$: Observable; - private _notifications$ = new BehaviorSubject([]); +export class NotificationsComponent { + readonly hasUnreadNotifications$: Observable; + readonly groupedNotifications$: Observable; constructor( private readonly _translateService: TranslateService, @@ -36,40 +31,30 @@ export class NotificationsComponent extends AutoUnsubscribe implements OnInit { private readonly _activeDossiersService: ActiveDossiersService, private readonly _datePipe: DatePipe, ) { - super(); - this.notifications$ = this._notifications$.asObservable().pipe(shareLast()); - this.groupedNotifications$ = this.notifications$.pipe(map(notifications => this._groupNotifications(notifications))); + this.groupedNotifications$ = this._notificationsService.all$.pipe(map(notifications => this._groupNotifications(notifications))); this.hasUnreadNotifications$ = this._hasUnreadNotifications$; } private get _hasUnreadNotifications$(): Observable { - return this.notifications$.pipe( + return this._notificationsService.all$.pipe( map(notifications => notifications.filter(n => !n.readDate).length > 0), distinctUntilChanged(), shareLast(), ); } - async ngOnInit(): Promise { - await this._loadData(); - - this.addSubscription = timer(CHANGED_CHECK_INTERVAL, CHANGED_CHECK_INTERVAL) - .pipe( - switchMap(() => this._notificationsService.getNotificationsIfChanged(INCLUDE_SEEN)), - tap(notifications => this._notifications$.next(notifications)), - ) - .subscribe(); - } - - async markRead($event, notifications: List = this._notifications$.getValue().map(n => n.id), isRead = true): Promise { + async markRead($event, notifications: Notification[] = this._notificationsService.all, isRead = true): Promise { $event.stopPropagation(); - await firstValueFrom(this._notificationsService.toggleNotificationRead(notifications, isRead)); - await this._loadData(); - } - - private async _loadData(): Promise { - const notifications = await firstValueFrom(this._notificationsService.getNotifications(INCLUDE_SEEN)); - this._notifications$.next(notifications); + if (!notifications.find(notification => !!notification.readDate !== isRead)) { + // If no notification changes status after the request, abort + return; + } + await firstValueFrom( + this._notificationsService.toggleNotificationRead( + notifications.map(n => n.id), + isRead, + ), + ); } private _groupNotifications(notifications: Notification[]): NotificationsGroup[] { diff --git a/apps/red-ui/src/app/modules/account/account-side-nav/account-side-nav.component.ts b/apps/red-ui/src/app/modules/account/account-side-nav/account-side-nav.component.ts index 9aafdd008..a3405ac88 100644 --- a/apps/red-ui/src/app/modules/account/account-side-nav/account-side-nav.component.ts +++ b/apps/red-ui/src/app/modules/account/account-side-nav/account-side-nav.component.ts @@ -22,7 +22,7 @@ export class AccountSideNavComponent { }, { screen: 'notifications', - label: _('notifications'), + label: _('notifications.label'), hideIf: !this._userService.currentUser.isUser, }, ]; diff --git a/apps/red-ui/src/app/modules/admin/screens/digital-signature/digital-signature-screen.component.ts b/apps/red-ui/src/app/modules/admin/screens/digital-signature/digital-signature-screen.component.ts index 732c73209..ba3adff2c 100644 --- a/apps/red-ui/src/app/modules/admin/screens/digital-signature/digital-signature-screen.component.ts +++ b/apps/red-ui/src/app/modules/admin/screens/digital-signature/digital-signature-screen.component.ts @@ -1,12 +1,13 @@ -import { Component, OnDestroy } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { lastIndexOfEnd } from '@utils/functions'; -import { AutoUnsubscribe, IconButtonTypes, LoadingService, Toaster } from '@iqser/common-ui'; +import { IconButtonTypes, LoadingService, Toaster } from '@iqser/common-ui'; import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; import { UserService } from '@services/user.service'; import { RouterHistoryService } from '@services/router-history.service'; import { DigitalSignatureService } from '../../services/digital-signature.service'; -import { IDigitalSignature, IDigitalSignatureRequest } from '@red/domain'; +import { IDigitalSignature } from '@red/domain'; +import { firstValueFrom } from 'rxjs'; import { HttpStatusCode } from '@angular/common/http'; @Component({ @@ -14,7 +15,7 @@ import { HttpStatusCode } from '@angular/common/http'; templateUrl: './digital-signature-screen.component.html', styleUrls: ['./digital-signature-screen.component.scss'], }) -export class DigitalSignatureScreenComponent extends AutoUnsubscribe implements OnDestroy { +export class DigitalSignatureScreenComponent implements OnInit { readonly iconButtonTypes = IconButtonTypes; readonly currentUser = this._userService.currentUser; @@ -30,16 +31,18 @@ export class DigitalSignatureScreenComponent extends AutoUnsubscribe implements private readonly _loadingService: LoadingService, readonly routerHistoryService: RouterHistoryService, private readonly _digitalSignatureService: DigitalSignatureService, - ) { - super(); - this.loadDigitalSignatureAndInitializeForm(); - } + ) {} get hasDigitalSignatureSet() { return this.digitalSignatureExists || !!this.form.get('base64EncodedPrivateKey').value; } - saveDigitalSignature() { + async ngOnInit(): Promise { + await this.loadDigitalSignatureAndInitializeForm(); + } + + async saveDigitalSignature(): Promise { + this._loadingService.start(); const formValue = this.form.getRawValue(); const digitalSignature: IDigitalSignature = { ...formValue, @@ -51,29 +54,32 @@ export class DigitalSignatureScreenComponent extends AutoUnsubscribe implements ? this._digitalSignatureService.update(digitalSignature) : this._digitalSignatureService.save(digitalSignature); - this.addSubscription = observable.subscribe({ - next: () => { - this.loadDigitalSignatureAndInitializeForm(); - this._toaster.success(_('digital-signature-screen.action.save-success')); - }, - error: error => { - if (error.status === HttpStatusCode.BadRequest) { - this._toaster.error(_('digital-signature-screen.action.certificate-not-valid-error')); - } else { - this._toaster.error(_('digital-signature-screen.action.save-error')); - } - }, - }); + try { + await firstValueFrom(observable); + await this.loadDigitalSignatureAndInitializeForm(); + this._toaster.success(_('digital-signature-screen.action.save-success')); + } catch (error) { + console.error(error); + if (error.status === HttpStatusCode.BadRequest) { + this._toaster.error(_('digital-signature-screen.action.certificate-not-valid-error')); + } else { + this._toaster.error(_('digital-signature-screen.action.save-error')); + } + } + + this._loadingService.stop(); } - removeDigitalSignature() { - this.addSubscription = this._digitalSignatureService.delete().subscribe( - () => { - this.loadDigitalSignatureAndInitializeForm(); - this._toaster.success(_('digital-signature-screen.action.delete-success')); - }, - () => this._toaster.error(_('digital-signature-screen.action.delete-error')), - ); + async removeDigitalSignature(): Promise { + this._loadingService.start(); + try { + await firstValueFrom(this._digitalSignatureService.delete()); + await this.loadDigitalSignatureAndInitializeForm(); + this._toaster.success(_('digital-signature-screen.action.delete-success')); + } catch (error) { + console.error(error); + this._toaster.error(_('digital-signature-screen.action.delete-error')); + } } fileChanged(event, input: HTMLInputElement) { @@ -89,24 +95,19 @@ export class DigitalSignatureScreenComponent extends AutoUnsubscribe implements fileReader.readAsDataURL(file as Blob); } - loadDigitalSignatureAndInitializeForm() { + async loadDigitalSignatureAndInitializeForm(): Promise { this._loadingService.start(); - this._digitalSignatureService - .getSignature() - .subscribe({ - next: digitalSignature => { - this.digitalSignatureExists = true; - this.digitalSignature = digitalSignature; - }, - error: () => { - this.digitalSignatureExists = false; - this.digitalSignature = {}; - }, - }) - .add(() => { - this.form = this._getForm(); - this._loadingService.stop(); - }); + try { + const digitalSignature = await firstValueFrom(this._digitalSignatureService.getSignature()); + this.digitalSignatureExists = true; + this.digitalSignature = digitalSignature; + } catch (error) { + this.digitalSignatureExists = false; + this.digitalSignature = {}; + } + + this.form = this._getForm(); + this._loadingService.stop(); } private _getForm(): FormGroup { diff --git a/apps/red-ui/src/app/services/notifications.service.ts b/apps/red-ui/src/app/services/notifications.service.ts index f25f49a88..e68359f12 100644 --- a/apps/red-ui/src/app/services/notifications.service.ts +++ b/apps/red-ui/src/app/services/notifications.service.ts @@ -1,56 +1,65 @@ -import { Injectable, Injector } from '@angular/core'; -import { GenericService, List, mapEach, QueryParam, RequiredParam, Validate } from '@iqser/common-ui'; +import { Inject, Injectable, Injector } from '@angular/core'; +import { EntitiesService, List, mapEach, QueryParam, RequiredParam, Validate } from '@iqser/common-ui'; import { TranslateService } from '@ngx-translate/core'; -import { EMPTY, iif, Observable } from 'rxjs'; -import { INotification, Notification, NotificationTypes } from '@red/domain'; -import { map, switchMap } from 'rxjs/operators'; +import { EMPTY, iif, Observable, of, timer } from 'rxjs'; +import { Dossier, INotification, Notification, NotificationTypes } from '@red/domain'; +import { map, switchMap, tap } from 'rxjs/operators'; import { notificationsTranslations } from '../translations/notifications-translations'; import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; import { ActiveDossiersService } from './dossiers/active-dossiers.service'; import { UserService } from '@services/user.service'; -import { FilesMapService } from '@services/entity-services/files-map.service'; import dayjs from 'dayjs'; +import { CHANGED_CHECK_INTERVAL } from '@utils/constants'; +import { BASE_HREF } from '../tokens'; +import { ArchivedDossiersService } from '@services/dossiers/archived-dossiers.service'; + +const INCLUDE_SEEN = false; @Injectable({ providedIn: 'root', }) -export class NotificationsService extends GenericService { +export class NotificationsService extends EntitiesService { constructor( + @Inject(BASE_HREF) private readonly _baseHref: string, protected readonly _injector: Injector, private readonly _translateService: TranslateService, private readonly _activeDossiersService: ActiveDossiersService, + private readonly _archivedDossiersService: ArchivedDossiersService, private readonly _userService: UserService, - private readonly _filesMapService: FilesMapService, ) { - super(_injector, 'notification'); + super(_injector, Notification, 'notification'); + + timer(0, CHANGED_CHECK_INTERVAL) + .pipe( + switchMap(() => (this._activeDossiersService.all.length ? of(null) : this._activeDossiersService.loadAll())), + switchMap(() => (this._archivedDossiersService.all.length ? of(null) : this._archivedDossiersService.loadAll())), + switchMap(() => this.#loadNotificationsIfChanged()), + ) + .subscribe(); } @Validate() - getNotifications(@RequiredParam() includeSeen: boolean): Observable { - let queryParam: QueryParam; - if (includeSeen !== undefined && includeSeen !== null) { - queryParam = { key: 'includeSeen', value: includeSeen }; - } - - return this.getAll<{ notifications: Notification[] }>(this._defaultModelPath, [queryParam]).pipe( - map(response => response.notifications.filter(n => n.notificationType in NotificationTypes)), - mapEach(notification => this._new(notification)), - ); - } - - @Validate() - getNotificationsIfChanged(@RequiredParam() includeSeen: boolean): Observable { - return this.hasChanges$().pipe(switchMap(changed => iif(() => changed, this.getNotifications(includeSeen), EMPTY))); - } - - @Validate() - toggleNotificationRead(@RequiredParam() body: List, @RequiredParam() setRead: boolean) { + toggleNotificationRead(@RequiredParam() body: List, @RequiredParam() setRead: boolean): Observable { let queryParam: QueryParam; if (setRead !== undefined && setRead !== null) { queryParam = { key: 'setRead', value: setRead }; } - return this._post(body, `${this._defaultModelPath}/toggle-read`, [queryParam]); + return this._post(body, `${this._defaultModelPath}/toggle-read`, [queryParam]).pipe(switchMap(() => this.#loadAll())); + } + + #loadAll(includeSeen = INCLUDE_SEEN): Observable { + const queryParam: QueryParam = { key: 'includeSeen', value: includeSeen }; + + return this.getAll<{ notifications: Notification[] }>(this._defaultModelPath, [queryParam]).pipe( + map(response => response.notifications.filter(n => n.notificationType in NotificationTypes)), + mapEach(notification => this._new(notification)), + tap(notifications => this.setEntities(notifications)), + ); + } + + #loadNotificationsIfChanged(): Observable { + return this.hasChanges$().pipe(switchMap(changed => iif(() => changed, this.#loadAll(), EMPTY))); } private _new(notification: INotification) { @@ -66,25 +75,27 @@ export class NotificationsService extends GenericService { private _translate(notification: INotification, translation: string): string { const fileId = notification.target.fileId; const dossierId = notification.target.dossierId; - const dossier = this._activeDossiersService.find(dossierId); + const dossier = this._activeDossiersService.find(dossierId) || this._archivedDossiersService.find(dossierId); const fileName = notification.target.fileName; return this._translateService.instant(translation, { - fileHref: this._getFileHref(dossierId, fileId), - dossierHref: this._getDossierHref(dossierId), - dossierName: notification.target?.dossierName || dossier?.dossierName || this._translateService.instant(_('dossier')), + fileHref: this._getFileHref(dossier, fileId), + dossierHref: this._getDossierHref(dossier), + dossierName: + notification.target?.dossierName || + dossier?.dossierName || + this._translateService.instant(_('notifications.deleted-dossier')), fileName: fileName || this._translateService.instant(_('file')), user: this._getUsername(notification.userId), }); } - private _getFileHref(dossierId: string, fileId: string): string { - const dossierHref = this._getDossierHref(dossierId); - return `${dossierHref}/file/${fileId}`; + private _getFileHref(dossier: Dossier, fileId: string): string { + return dossier ? `${this._getDossierHref(dossier)}/file/${fileId}` : null; } - private _getDossierHref(dossierId: string): string { - return `/ui/main/dossiers/${dossierId}`; + private _getDossierHref(dossier: Dossier): string { + return dossier ? `${this._baseHref}${dossier.routerLink}` : null; } private _getUsername(userId: string | undefined) { diff --git a/apps/red-ui/src/assets/i18n/de.json b/apps/red-ui/src/assets/i18n/de.json index 0bbc03d45..9de143c7d 100644 --- a/apps/red-ui/src/assets/i18n/de.json +++ b/apps/red-ui/src/assets/i18n/de.json @@ -665,7 +665,6 @@ "save": "Dokumenteninformation speichern", "title": "Datei-Attribute anlegen" }, - "dossier": "Dossier", "dossier-attribute-types": { "date": "Datum", "image": "Bild", @@ -1530,21 +1529,20 @@ } }, "notification": { - "assign-approver": "Sie wurden dem Dokument {fileName} im Dossier {dossierName} als Genehmiger zugewiesen!", - "assign-reviewer": "Sie wurden dem Dokument {fileName} im Dossier {dossierName} als Reviewer zugewiesen!", - "document-approved": "{fileName} wurde genehmigt!", + "assign-approver": "Sie wurden dem Dokument {fileHref, select, null{{fileName}} other{{fileName}}} im Dossier {dossierHref, select, null{{dossierName}} other{{dossierName}}} als Genehmiger zugewiesen!", + "assign-reviewer": "Sie wurden dem Dokument {fileHref, select, null{{fileName}} other{{fileName}}} im Dossier {dossierHref, select, null{{dossierName}} other{{dossierName}}} als Reviewer zugewiesen!", + "document-approved": "{fileHref, select, null{{fileName}} other{{fileName}}} wurde genehmigt!", "dossier-deleted": "Dossier: {dossierName} wurde gelöscht!", - "dossier-owner-removed": "Der Dossier-Owner von {dossierName} wurde entfernt!", - "dossier-owner-set": "Eigentümer von {dossierName} geändert zu {user}!", + "dossier-owner-removed": "Der Dossier-Owner von {dossierHref, select, null{{dossierName}} other{{dossierName}}} wurde entfernt!", + "dossier-owner-set": "Eigentümer von {dossierHref, select, null{{dossierName}} other{{dossierName}}} geändert zu {user}!", "download-ready": "Ihr Download ist fertig!", "no-data": "Du hast aktuell keine Benachrichtigungen", - "unassigned-from-file": "Sie wurden vom Dokument {fileName} im Dossier {dossierName} entfernt!", - "user-becomes-dossier-member": "{user} ist jetzt Mitglied des Dossiers {dossierName}!", - "user-demoted-to-reviewer": "{user} wurde im Dossier {dossierName} auf die Reviewer-Berechtigung heruntergestuft!", - "user-promoted-to-approver": "{user} wurde im Dossier {dossierName} zum Genehmiger ernannt!", - "user-removed-as-dossier-member": "{user} wurde als Mitglied von: {dossierName} entfernt!" + "unassigned-from-file": "Sie wurden vom Dokument {fileHref, select, null{{fileName}} other{{fileName}}} im Dossier {dossierHref, select, null{{dossierName}} other{{dossierName}}} entfernt!", + "user-becomes-dossier-member": "{user} ist jetzt Mitglied des Dossiers {dossierHref, select, null{{dossierName}} other{{dossierName}}}!", + "user-demoted-to-reviewer": "{user} wurde im Dossier {dossierHref, select, null{{dossierName}} other{{dossierName}}} auf die Reviewer-Berechtigung heruntergestuft!", + "user-promoted-to-approver": "{user} wurde im Dossier {dossierHref, select, null{{dossierName}} other{{dossierName}}} zum Genehmiger ernannt!", + "user-removed-as-dossier-member": "{user} wurde als Mitglied von: {dossierHref, select, null{{dossierName}} other{{dossierName}}} entfernt!" }, - "notifications": "Benachrichtigungen", "notifications-screen": { "category": { "email-notifications": "E-Mail Benachrichtigungen", @@ -1583,6 +1581,11 @@ }, "title": "Benachrichtigungseinstellungen" }, + "notifications": { + "deleted-dossier": "", + "label": "Benachrichtigungen", + "mark-as": "" + }, "ocr": { "confirmation-dialog": { "cancel": "", diff --git a/apps/red-ui/src/assets/i18n/en.json b/apps/red-ui/src/assets/i18n/en.json index ff7c38da1..c314b77f9 100644 --- a/apps/red-ui/src/assets/i18n/en.json +++ b/apps/red-ui/src/assets/i18n/en.json @@ -665,7 +665,6 @@ "save": "Save Document Info", "title": "Introduce File Attributes" }, - "dossier": "Dossier", "dossier-attribute-types": { "date": "Date", "image": "Image", @@ -1530,21 +1529,20 @@ } }, "notification": { - "assign-approver": "You have been assigned as approver for {fileName} in dossier: {dossierName}!", - "assign-reviewer": "You have been assigned as reviewer for {fileName} in dossier: {dossierName}!", - "document-approved": " {fileName} has been approved!", + "assign-approver": "You have been assigned as approver for {fileHref, select, null{{fileName}} other{{fileName}}} in dossier: {dossierHref, select, null{{dossierName}} other{{dossierName}}}!", + "assign-reviewer": "You have been assigned as reviewer for {fileHref, select, null{{fileName}} other{{fileName}}} in dossier: {dossierHref, select, null{{dossierName}} other{{dossierName}}}!", + "document-approved": " {fileHref, select, null{{fileName}} other{{fileName}}} has been approved!", "dossier-deleted": "Dossier: {dossierName} has been deleted!", - "dossier-owner-removed": "You have been removed as dossier owner from {dossierName}!", - "dossier-owner-set": "You are now the dossier owner of {dossierName}!", + "dossier-owner-removed": "You have been removed as dossier owner from {dossierHref, select, null{{dossierName}} other{{dossierName}}}!", + "dossier-owner-set": "You are now the dossier owner of {dossierHref, select, null{{dossierName}} other{{dossierName}}}!", "download-ready": "Your download is ready!", "no-data": "You currently have no notifications", - "unassigned-from-file": "You have been unassigned from {fileName} in dossier: {dossierName}!", - "user-becomes-dossier-member": "You have been added to dossier: {dossierName}!", - "user-demoted-to-reviewer": "You have been demoted to reviewer in dossier: {dossierName}!", - "user-promoted-to-approver": "You have been promoted to approver in dossier: {dossierName}!", - "user-removed-as-dossier-member": "You have been removed as a member from dossier: {dossierName} !" + "unassigned-from-file": "You have been unassigned from {fileHref, select, null{{fileName}} other{{fileName}}} in dossier: {dossierHref, select, null{{dossierName}} other{{dossierHref, select, null{{dossierName}} other{{dossierName}}}}}!", + "user-becomes-dossier-member": "You have been added to dossier: {dossierHref, select, null{{dossierName}} other{{dossierName}}}!", + "user-demoted-to-reviewer": "You have been demoted to reviewer in dossier: {dossierHref, select, null{{dossierName}} other{{dossierName}}}!", + "user-promoted-to-approver": "You have been promoted to approver in dossier: {dossierHref, select, null{{dossierName}} other{{dossierName}}}!", + "user-removed-as-dossier-member": "You have been removed as a member from dossier: {dossierHref, select, null{{dossierName}} other{{dossierName}}}!" }, - "notifications": "Notifications", "notifications-screen": { "category": { "email-notifications": "Email Notifications", @@ -1583,6 +1581,11 @@ }, "title": "Notifications Preferences" }, + "notifications": { + "deleted-dossier": "Deleted Dossier", + "label": "Notifications", + "mark-as": "Mark as {type, select, read{read} unread{unread} other{}}" + }, "ocr": { "confirmation-dialog": { "cancel": "Cancel", From 6efd07b9da093003d477dd2d29fa2fbe8e245fff Mon Sep 17 00:00:00 2001 From: Atlassian Bamboo Date: Mon, 28 Mar 2022 18:33:21 +0000 Subject: [PATCH 2/2] chore(release) --- package.json | 2 +- paligo-theme.tar.gz | Bin 3215 -> 3211 bytes 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 66ecaf42d..f2d09f236 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "redaction", - "version": "3.376.0", + "version": "3.377.0", "private": true, "license": "MIT", "scripts": { diff --git a/paligo-theme.tar.gz b/paligo-theme.tar.gz index 7c89c48202864f8a3f63cf99213b1213496101a2..62591251146efdfe677412778f23ee940ee1ffb6 100644 GIT binary patch literal 3211 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