diff --git a/CHANGELOG.md b/CHANGELOG.md index 43a0e9077a..0ad4d0a330 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,11 +23,13 @@ _**For better traceability add the corresponding GitHub issue number in each cha - #639 handle expired or incorrect policies when sending notifications - #786 Added authorization as admin for submodel api & registry api - #884 Upgraded tractionBatteryCode from 1.0.0 to 2.0.0 +- #1009 reimplemented retry request logic for notification approval - #786 Added alternative port (only accessible within same cluster) for application which is used for unsecured API endpoints. - #786 Introduced internal url for notification contracts. - #994 improved bpn edc configuration view uux - #1082 fix update of parts when synchronizing with IRS + ### Added - #832 added policymanagement list view, creator and editor - #737 Added concept: Contract table -> parts link action diff --git a/frontend/src/app/modules/core/api/api.service.ts b/frontend/src/app/modules/core/api/api.service.ts index 22b2196943..51551800a7 100644 --- a/frontend/src/app/modules/core/api/api.service.ts +++ b/frontend/src/app/modules/core/api/api.service.ts @@ -28,6 +28,7 @@ import { AuthService } from '../auth/auth.service'; providedIn: 'root', }) export class ApiService { + lastRequest: { execute?: () => Observable, context?: any }; constructor(private readonly httpClient: HttpClient, private readonly authService: AuthService) { } @@ -111,6 +112,16 @@ export class ApiService { }); } + /** + * set the public class property 'lastRequest' from where you made the request + * before retrying + */ + public retryLastRequest(): Observable | null { + if (this.lastRequest.execute) { + return this.lastRequest.execute(); + } + } + private buildHeaders(): HttpHeaders { return new HttpHeaders({ Access: 'application/json', diff --git a/frontend/src/app/modules/core/user/table-settings.service.spec.ts b/frontend/src/app/modules/core/user/table-settings.service.spec.ts index bafd29d9a1..e7021fe92d 100644 --- a/frontend/src/app/modules/core/user/table-settings.service.spec.ts +++ b/frontend/src/app/modules/core/user/table-settings.service.spec.ts @@ -17,18 +17,22 @@ * SPDX-License-Identifier: Apache-2.0 ********************************************************************************/ +import { HttpClientTestingModule } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; +import { MockedKeycloakService } from '@core/auth/mocked-keycloak.service'; import { TableSettingsService } from '@core/user/table-settings.service'; import { TableType } from '@shared/components/multi-select-autocomplete/table-type.model'; import { TableViewConfig } from '@shared/components/parts-table/table-view-config.model'; +import { ToastService } from '@shared/components/toasts/toast.service'; +import { KeycloakService } from 'keycloak-angular'; describe('TableSettingsService', () => { let service: TableSettingsService; beforeEach(() => { TestBed.configureTestingModule({ - imports: [], - providers: [ TableSettingsService ], + imports: [ HttpClientTestingModule ], + providers: [ TableSettingsService, ToastService, { provide: KeycloakService, useValue: MockedKeycloakService } ], }); service = TestBed.inject(TableSettingsService); }); diff --git a/frontend/src/app/modules/page/notifications/detail/notification-detail.component.html b/frontend/src/app/modules/page/notifications/detail/notification-detail.component.html index 7dbf617f20..60680a8679 100644 --- a/frontend/src/app/modules/page/notifications/detail/notification-detail.component.html +++ b/frontend/src/app/modules/page/notifications/detail/notification-detail.component.html @@ -290,7 +290,7 @@ - + diff --git a/frontend/src/app/modules/page/notifications/detail/notification-detail.component.spec.ts b/frontend/src/app/modules/page/notifications/detail/notification-detail.component.spec.ts index 18f3c54819..390174a3c1 100644 --- a/frontend/src/app/modules/page/notifications/detail/notification-detail.component.spec.ts +++ b/frontend/src/app/modules/page/notifications/detail/notification-detail.component.spec.ts @@ -20,10 +20,12 @@ import { ActivatedRoute } from '@angular/router'; import { NotificationDetailComponent } from '@page/notifications/detail/notification-detail.component'; import { NotificationsModule } from '@page/notifications/notifications.module'; +import { NotificationAssembler } from '@shared/assembler/notification.assembler'; import { NotificationService } from '@shared/service/notification.service'; import { screen, waitFor } from '@testing-library/angular'; import { renderComponent } from '@tests/test-render.utils'; import { of } from 'rxjs'; +import { MockEmptyAlert } from '../../../../mocks/services/alerts-mock/alerts.test.model'; describe('NotificationDetailComponent', () => { @@ -58,4 +60,23 @@ describe('NotificationDetailComponent', () => { await waitFor(() => expect(screen.getByText('actions.goBack')).toBeInTheDocument()); }); + it('should correctly behave on toast retry action', async () => { + const { fixture } = await renderNotificationDetail('id-1'); + const { componentInstance } = fixture; + const ngSpy = spyOn(componentInstance, 'ngAfterViewInit').and.returnValue(null); + const toastSuccessSpy = spyOn(componentInstance['toastService'], 'success'); + const toastErrorSpy = spyOn(componentInstance['toastService'], 'error'); + + componentInstance.selectedNotification = NotificationAssembler.assembleNotification(MockEmptyAlert); + componentInstance['toastService'].retryAction.emit({ success: true }); + expect(toastSuccessSpy).toHaveBeenCalled(); + + componentInstance['toastService'].retryAction.emit({ error: true }); + expect(toastErrorSpy).toHaveBeenCalled(); + + expect(ngSpy).toHaveBeenCalled(); + + }); + + }); diff --git a/frontend/src/app/modules/page/notifications/detail/notification-detail.component.ts b/frontend/src/app/modules/page/notifications/detail/notification-detail.component.ts index 53548623e9..da17a6aba8 100644 --- a/frontend/src/app/modules/page/notifications/detail/notification-detail.component.ts +++ b/frontend/src/app/modules/page/notifications/detail/notification-detail.component.ts @@ -67,6 +67,7 @@ export class NotificationDetailComponent implements AfterViewInit, OnDestroy { public selectedNotification: Notification; private paramSubscription: Subscription; + private toastActionSubscription: Subscription; constructor( public readonly helperService: NotificationHelperService, @@ -81,6 +82,18 @@ export class NotificationDetailComponent implements AfterViewInit, OnDestroy { this.notificationPartsInformation$ = this.notificationDetailFacade.notificationPartsInformation$; this.supplierPartsDetailInformation$ = this.notificationDetailFacade.supplierPartsInformation$; + this.toastActionSubscription = this.toastService.retryAction.subscribe({ + next: result => { + const formattedStatus = result?.context?.charAt(0)?.toUpperCase() + result?.context?.slice(1)?.toLowerCase(); + if (result?.success) { + this.toastService.success(`requestNotification.successfully${ formattedStatus }`); + } else if (result?.error) { + this.toastService.error(`requestNotification.failed${ formattedStatus }`, 15000, true); + } + this.ngAfterViewInit(); + }, + }); + this.selected$ = this.notificationDetailFacade.selected$; this.paramSubscription = this.route.queryParams.subscribe(params => { @@ -111,6 +124,7 @@ export class NotificationDetailComponent implements AfterViewInit, OnDestroy { this.subscription?.unsubscribe(); this.notificationDetailFacade.unsubscribeSubscriptions(); this.paramSubscription?.unsubscribe(); + this.toastActionSubscription?.unsubscribe(); } public navigateToEditView() { diff --git a/frontend/src/app/modules/page/notifications/presentation/notifications.component.spec.ts b/frontend/src/app/modules/page/notifications/presentation/notifications.component.spec.ts index 3964e2636f..e77282b1d9 100644 --- a/frontend/src/app/modules/page/notifications/presentation/notifications.component.spec.ts +++ b/frontend/src/app/modules/page/notifications/presentation/notifications.component.spec.ts @@ -167,4 +167,21 @@ describe('NotificationsComponent', () => { expect(notificationsComponent['notificationReceivedSortList']).toEqual([]); }); + it('should correctly behave on toast retry action', async () => { + const { fixture } = await renderNotifications(); + const { componentInstance } = fixture; + const handleConfirmSpy = spyOn(componentInstance, 'handleConfirmActionCompletedEvent'); + const toastSuccessSpy = spyOn(componentInstance['toastService'], 'success'); + const toastErrorSpy = spyOn(componentInstance['toastService'], 'error'); + + componentInstance['toastService'].retryAction.emit({ success: true }); + expect(toastSuccessSpy).toHaveBeenCalled(); + + componentInstance['toastService'].retryAction.emit({ error: true }); + expect(toastErrorSpy).toHaveBeenCalled(); + + expect(handleConfirmSpy).toHaveBeenCalled(); + + }); + }); diff --git a/frontend/src/app/modules/page/notifications/presentation/notifications.component.ts b/frontend/src/app/modules/page/notifications/presentation/notifications.component.ts index b6516c3c8f..252a610543 100644 --- a/frontend/src/app/modules/page/notifications/presentation/notifications.component.ts +++ b/frontend/src/app/modules/page/notifications/presentation/notifications.component.ts @@ -28,6 +28,7 @@ import { NotificationChannel } from '@shared/components/multi-select-autocomplet import { NotificationCommonModalComponent } from '@shared/components/notification-common-modal/notification-common-modal.component'; import { TableSortingUtil } from '@shared/components/table/table-sorting.util'; import { MenuActionConfig, TableEventConfig, TableHeaderSort } from '@shared/components/table/table.model'; +import { ToastService } from '@shared/components/toasts/toast.service'; import { createDeeplinkNotificationFilter } from '@shared/helper/notification-helper'; import { setMultiSorting } from '@shared/helper/table-helper'; import { NotificationTabInformation } from '@shared/model/notification-tab-information'; @@ -58,6 +59,7 @@ export class NotificationsComponent { private ctrlKeyState: boolean = false; private paramSubscription: Subscription; + private toastActionSubscription: Subscription; receivedFilter: NotificationFilter; requestedFilter: NotificationFilter; @@ -71,6 +73,7 @@ export class NotificationsComponent { private readonly router: Router, private readonly route: ActivatedRoute, private readonly cd: ChangeDetectorRef, + private readonly toastService: ToastService, ) { this.notificationsReceived$ = this.notificationsFacade.notificationsReceived$; this.notificationsQueuedAndRequested$ = this.notificationsFacade.notificationsQueuedAndRequested$; @@ -81,6 +84,18 @@ export class NotificationsComponent { window.addEventListener('keyup', (event) => { this.ctrlKeyState = setMultiSorting(event); }); + + this.toastActionSubscription = this.toastService.retryAction.subscribe({ + next: result => { + const formatted = result?.context?.charAt(0)?.toUpperCase() + result?.context?.slice(1)?.toLowerCase(); + if (result?.success) { + this.toastService.success(`requestNotification.successfully${ formatted }`); + } else if (result?.error) { + this.toastService.error(`requestNotification.failed${ formatted }`, 15000, true); + } + this.handleConfirmActionCompletedEvent(); + }, + }); } public ngOnInit(): void { @@ -106,6 +121,7 @@ export class NotificationsComponent { public ngOnDestroy(): void { this.notificationsFacade.stopNotifications(); this.paramSubscription?.unsubscribe(); + this.toastActionSubscription?.unsubscribe(); } public onReceivedTableConfigChange(pagination: TableEventConfig) { diff --git a/frontend/src/app/modules/shared/components/notification-reason/notification-reason.component.spec.ts b/frontend/src/app/modules/shared/components/notification-reason/notification-reason.component.spec.ts index c2990cae6d..9ec1ce13ee 100644 --- a/frontend/src/app/modules/shared/components/notification-reason/notification-reason.component.spec.ts +++ b/frontend/src/app/modules/shared/components/notification-reason/notification-reason.component.spec.ts @@ -72,7 +72,8 @@ describe('NotificationReasonComponent', () => { }, ] }; - component.notification = notification; + component.notificationMessages = notification.messages; + component.ngOnInit(); expect(component.textMessages.length).toBe(2); expect(component.textMessages[0].message).toEqual('Hello'); expect(component.textMessages[1].direction).toEqual('left'); @@ -102,7 +103,7 @@ describe('NotificationReasonComponent', () => { isFromSender: true, messages: [] }; - component.notification = notification; + component.notificationMessages = notification.messages; expect(component.textMessages.length).toBe(0); }); @@ -122,7 +123,7 @@ describe('NotificationReasonComponent', () => { isFromSender: true, messages: [] }; - component.notification = notification; + component.notificationMessages = notification.messages; // Since date is invalid, sorting and processing might behave unexpectedly, expecting no error thrown and no messages processed expect(component.textMessages.length).toBe(0); }); diff --git a/frontend/src/app/modules/shared/components/notification-reason/notification-reason.component.ts b/frontend/src/app/modules/shared/components/notification-reason/notification-reason.component.ts index 9d87c707d9..031e01ce2b 100644 --- a/frontend/src/app/modules/shared/components/notification-reason/notification-reason.component.ts +++ b/frontend/src/app/modules/shared/components/notification-reason/notification-reason.component.ts @@ -21,7 +21,7 @@ import { Component, Input } from '@angular/core'; import { environment } from '@env'; -import { Notification, NotificationStatus } from '@shared/model/notification.model'; +import { NotificationMessage, NotificationStatus } from '@shared/model/notification.model'; type TextMessageDirection = 'left' | 'right'; @@ -42,31 +42,26 @@ interface TextMessage { }) export class NotificationReasonComponent { public textMessages: TextMessage[] = []; + @Input() notificationMessages: NotificationMessage[]; - @Input() set notification({ - description, - status, - isFromSender, - createdDate, - createdBy, - createdByName, - sendTo, - sendToName, - messages, - }: Notification) { + ngOnInit() { + if (!this.notificationMessages) { + return; + } + let sortedMessagesAfterDates = [ ...this.notificationMessages ]; - const sortedMessagesAfterDates = messages.sort((a, b) => new Date(a.messageDate).valueOf() - new Date(b.messageDate).valueOf()); + sortedMessagesAfterDates.sort((a, b) => new Date(a.messageDate).valueOf() - new Date(b.messageDate).valueOf()); - sortedMessagesAfterDates.forEach(message => { - this.textMessages.push({ - message: message.message, - direction: environment.bpn === message.sentBy ? 'right' : 'left', - user: message.sentByName, - bpn: message.sentBy, - status: message.status, - date: message.messageDate, - errorMessage: message.errorMessage, - }); + sortedMessagesAfterDates?.forEach(message => { + this.textMessages.push({ + message: message.message, + direction: environment.bpn === message.sentBy ? 'right' : 'left', + user: message.sentByName, + bpn: message.sentBy, + status: message.status, + date: message.messageDate, + errorMessage: message.errorMessage, + }); }); } diff --git a/frontend/src/app/modules/shared/components/toasts/toast.service.ts b/frontend/src/app/modules/shared/components/toasts/toast.service.ts index 456152ffae..aa5b153128 100644 --- a/frontend/src/app/modules/shared/components/toasts/toast.service.ts +++ b/frontend/src/app/modules/shared/components/toasts/toast.service.ts @@ -20,6 +20,7 @@ ********************************************************************************/ import { EventEmitter, Injectable } from '@angular/core'; +import { ApiService } from '@core/api/api.service'; import { I18nMessage } from '@shared/model/i18n-message'; import { Observable, Subject } from 'rxjs'; import { CallAction, ToastMessage, ToastStatus } from './toast-message/toast-message.model'; @@ -32,6 +33,9 @@ export class ToastService { private idx = 0; retryAction = new EventEmitter(); + constructor(private readonly apiService: ApiService) { + } + public getCurrentToast$(): Observable { return this.toastStore.asObservable(); } @@ -56,7 +60,10 @@ export class ToastService { this.toastStore.next(new ToastMessage(this.idx++, message, ToastStatus.Warning, timeout)); } - public emitClick(event?: any) { - this.retryAction.emit(); + public emitClick(): void { + this.apiService.retryLastRequest()?.subscribe({ + next: (next) => this.retryAction.emit({ success: next, context: this.apiService.lastRequest?.context }), + error: (err) => this.retryAction.emit({ error: err, context: this.apiService.lastRequest?.context }), + }); }; } diff --git a/frontend/src/app/modules/shared/components/toasts/toast.spec.ts b/frontend/src/app/modules/shared/components/toasts/toast.spec.ts index 0ef692871f..89ff281a4c 100644 --- a/frontend/src/app/modules/shared/components/toasts/toast.spec.ts +++ b/frontend/src/app/modules/shared/components/toasts/toast.spec.ts @@ -19,15 +19,19 @@ * SPDX-License-Identifier: Apache-2.0 ********************************************************************************/ -import {TestBed} from '@angular/core/testing'; -import {SharedModule} from '@shared/shared.module'; -import {screen} from '@testing-library/angular'; -import {renderComponent} from '@tests/test-render.utils'; -import {ToastService} from './toast.service'; +import { TestBed } from '@angular/core/testing'; +import { ApiService } from '@core/api/api.service'; +import { SharedModule } from '@shared/shared.module'; +import { screen } from '@testing-library/angular'; +import { renderComponent } from '@tests/test-render.utils'; +import { ToastService } from './toast.service'; describe('toasts', () => { const renderToastLayout = async () => { - await renderComponent(``, { imports: [ SharedModule ] }); + await renderComponent(``, { + imports: [ SharedModule ], + providers: [ ApiService ], + }); return TestBed.inject(ToastService); }; @@ -68,7 +72,7 @@ describe('toasts', () => { it('should emit click action on toast', async () => { const toastService = await renderToastLayout(); - const toastActionSpy = spyOn(toastService.retryAction, 'emit') + const toastActionSpy = spyOn(toastService['apiService'], 'retryLastRequest'); toastService.emitClick(); expect(toastActionSpy).toHaveBeenCalled(); }); diff --git a/frontend/src/app/modules/shared/modules/notification/modal/content/notification-modal-content.component.html b/frontend/src/app/modules/shared/modules/notification/modal/content/notification-modal-content.component.html index 830742e95f..62b05594c9 100644 --- a/frontend/src/app/modules/shared/modules/notification/modal/content/notification-modal-content.component.html +++ b/frontend/src/app/modules/shared/modules/notification/modal/content/notification-modal-content.component.html @@ -27,7 +27,7 @@ - + diff --git a/frontend/src/app/modules/shared/service/notification.service.ts b/frontend/src/app/modules/shared/service/notification.service.ts index 692fccd9b7..b809b7930d 100644 --- a/frontend/src/app/modules/shared/service/notification.service.ts +++ b/frontend/src/app/modules/shared/service/notification.service.ts @@ -92,17 +92,32 @@ export class NotificationService { public closeNotification(id: string, reason: string): Observable { const requestUrl = this.notificationUrl(); const body = { reason }; - return this.apiService.post(`${ requestUrl }/${ id }/close`, body); + const request = () => this.apiService.post(`${ requestUrl }/${ id }/close`, body); + this.apiService.lastRequest = { + context: NotificationStatus.CLOSED, + execute: request, + }; + return request(); } public approveNotification(id: string): Observable { const requestUrl = this.notificationUrl(); - return this.apiService.post(`${ requestUrl }/${ id }/approve`); + const request = () => this.apiService.post(`${ requestUrl }/${ id }/approve`); + this.apiService.lastRequest = { + context: NotificationStatus.APPROVED, + execute: request, + }; + return request(); } public cancelNotification(id: string): Observable { const requestUrl = this.notificationUrl(); - return this.apiService.post(`${ requestUrl }/${ id }/cancel`); + const request = () => this.apiService.post(`${ requestUrl }/${ id }/cancel`); + this.apiService.lastRequest = { + context: NotificationStatus.CANCELED, + execute: request, + }; + return request(); } public updateNotification( @@ -112,7 +127,12 @@ export class NotificationService { ): Observable { const requestUrl = this.notificationUrl(); const body = { reason, status }; - return this.apiService.post(`${ requestUrl }/${ id }/update`, body); + const request = () => this.apiService.post(`${ requestUrl }/${ id }/update`, body); + this.apiService.lastRequest = { + context: status, + execute: request, + }; + return request(); } public editNotification(notificationId: string, title: string, receiverBpn: string, severity: string, targetDate: string, description: string, affectedPartIds: string[]): Observable { diff --git a/frontend/src/assets/locales/de/common.json b/frontend/src/assets/locales/de/common.json index 4cbe777242..bd1581ca4a 100644 --- a/frontend/src/assets/locales/de/common.json +++ b/frontend/src/assets/locales/de/common.json @@ -291,6 +291,8 @@ "saveEditSuccess": "Qualitätsthema wurde erfolgreich aktualisiert.", "saveEditError": "Bei der Aktualisierung des Qualitätsthemas ist ein Fehler aufgetreten.", "save" : "Qualitätsthema speichern", + "successfullyApproved" : "Qualitätsthema wurde erfolgreich genehmigt.", + "failedApprove" : "Qualitätsthema konnte nicht genehmigt werden, bitte versuchen Sie es erneut.", "noChanges" : "Erfordert eine Änderung am Qualitätsthema" }, "editNotification": { diff --git a/frontend/src/assets/locales/en/common.json b/frontend/src/assets/locales/en/common.json index 5d0ce24877..bb71be4ce4 100644 --- a/frontend/src/assets/locales/en/common.json +++ b/frontend/src/assets/locales/en/common.json @@ -290,6 +290,18 @@ "saveEditSuccess": "Quality topic was updated successfully.", "saveEditError": "An error occurred while updating the quality topic.", "save" : "Save quality topic", + "successfullyAccepted" : "Notification was accepted successfully.", + "successfullyAcknowledged" : "Notification was acknowledged successfully.", + "successfullyApproved" : "Notification was approved successfully.", + "successfullyCanceled" : "Notification was canceled successfully.", + "successfullyClosed" : "Notification was closed successfully.", + "successfullyDeclined" : "Notification was declined successfully.", + "failedAccepted" : "Notification failed to accept, please try again.", + "failedAcknowledged" : "Notification failed to acknowledge, please try again.", + "failedApproved" : "Notification failed to approve, please try again.", + "failedCanceled" : "Notification failed to cancel, please try again.", + "failedClosed" : "Notification failed to close, please try again.", + "failedDeclined" : "Notification failed to decline, please try again.", "noChanges" : "Requires a change on the quality topic" }, "editNotification": {