From: Afreen Misbah Date: Tue, 14 Jul 2026 19:06:45 +0000 (+0530) Subject: mgr/dashboard: Add View more link to toasts X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=8cb093238288a48d5647a4693bf0c0d1a5e311d9;p=ceph.git mgr/dashboard: Add View more link to toasts - adds view more link to toast which takes to view full toast message - allow notifications to be clickable in notification panel taking user to the particular notification - add routes to eahc notitification in notificatio page - extend duration for error notifications - as per carbon error notifications should persist longer since users need time to read Signed-off-by: Afreen Misbah --- diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.html index 27a30ab40ad0..08a67d7e9f72 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.html @@ -48,8 +48,12 @@ >
>>>>>> 8e9c61bcc14 (mgr/dashboard: Fix the footer - view all button css in notification panel) } .notification-empty { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.spec.ts index 36298f4b109f..1f30eb0e4c44 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.spec.ts @@ -49,6 +49,7 @@ describe('NotificationAreaComponent', () => { mockDataSource = new BehaviorSubject(mockNotifications); const spy = { remove: jasmine.createSpy('remove'), + removeById: jasmine.createSpy('removeById').and.returnValue(true), dataSource: mockDataSource, data$: mockDataSource.asObservable(), getNotificationsSnapshot: () => mockDataSource.getValue() diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.ts index cd323d29d92a..d27ee54ddb83 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.ts @@ -10,6 +10,7 @@ import moment from 'moment'; import { ExecutingTask } from '~/app/shared/models/executing-task'; import { TaskMessageService } from '~/app/shared/services/task-message.service'; import { Icons } from '~/app/shared/enum/icons.enum'; +import { Router } from '@angular/router'; @Component({ selector: 'cd-notification-area', @@ -30,7 +31,8 @@ export class NotificationAreaComponent implements OnInit, OnDestroy { constructor( private notificationService: NotificationService, private summaryService: SummaryService, - private taskMessageService: TaskMessageService + private taskMessageService: TaskMessageService, + private router: Router ) {} ngOnInit(): void { @@ -93,4 +95,17 @@ export class NotificationAreaComponent implements OnInit, OnDestroy { ngOnDestroy(): void { this.subs.unsubscribe(); } + + navigateToNotification(notification: CdNotification) { + this.notificationService.togglePanel(false); + this.router.navigate(['/notifications'], { + queryParams: { id: notification.id } + }); + } + + removeNotification(notification: CdNotification, event: MouseEvent) { + event.stopPropagation(); + event.preventDefault(); + this.notificationService.removeById(notification.id); + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-item/notification-item.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-item/notification-item.component.spec.ts index 12d17660aff1..cf749081f866 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-item/notification-item.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-item/notification-item.component.spec.ts @@ -162,17 +162,13 @@ describe('NotificationItemComponent', () => { expect(appEl).toBeNull(); }); - it('should call notificationService.remove and emit deleted on delete', () => { - const config = new CdNotificationConfig(NotificationType.error, 'Test', 'msg'); - const notification = new CdNotification(config); - notification.id = 'test-1'; - spyOn(notificationService, 'getNotificationsSnapshot').and.returnValue([notification]); - spyOn(notificationService, 'remove'); + it('should call notificationService.removeById and emit deleted on delete', () => { + spyOn(notificationService, 'removeById').and.returnValue(true); const deleteBtn = fixture.nativeElement.querySelector('.cd-notification-item__delete'); deleteBtn.click(); - expect(notificationService.remove).toHaveBeenCalledWith(0); + expect(notificationService.removeById).toHaveBeenCalledWith('test-1'); expect(hostComponent.deletedId).toBe('test-1'); }); @@ -209,13 +205,12 @@ describe('NotificationItemComponent', () => { }); it('should not emit deleted when notification is not found', () => { - spyOn(notificationService, 'getNotificationsSnapshot').and.returnValue([]); - spyOn(notificationService, 'remove'); + spyOn(notificationService, 'removeById').and.returnValue(false); const deleteBtn = fixture.nativeElement.querySelector('.cd-notification-item__delete'); deleteBtn.click(); - expect(notificationService.remove).not.toHaveBeenCalled(); + expect(notificationService.removeById).toHaveBeenCalledWith('test-1'); expect(hostComponent.deletedId).toBeNull(); }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-item/notification-item.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-item/notification-item.component.ts index 7e0d01953d14..04aabf9e61f0 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-item/notification-item.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-item/notification-item.component.ts @@ -46,10 +46,7 @@ export class NotificationItemComponent { onDelete(event: Event): void { event.stopPropagation(); - const notifications = this.notificationService.getNotificationsSnapshot(); - const index = notifications.findIndex((n) => n.id === this.notificationId); - if (index > -1) { - this.notificationService.remove(index); + if (this.notificationService.removeById(this.notificationId)) { this.deleted.emit(this.notificationId); } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.html index 1945ac7474fc..ad19191f3391 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.html @@ -19,16 +19,41 @@ > Notifications - - Clear all - +
  • + +
  • +
  • + +
  • +
    @@ -84,6 +109,11 @@ >
    + @if (selected.occurrences > 1) { +

    + Occurrences: {{ selected.occurrences }} +

    + }

    {{ selected.displayPreview }}

    diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.scss index 4d5d87697f74..fea934ca1b26 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.scss @@ -18,10 +18,10 @@ display: inline-flex; align-items: center; gap: var(--cds-spacing-02); + } - &:last-child { - justify-self: end; - } + &__header-menu { + justify-self: end; } &__header-title { @@ -79,6 +79,11 @@ } } + &__occurrences { + margin: 0 0 var(--cds-spacing-03) 0; + color: var(--cds-text-secondary); + } + &__detail-text { margin: 0; color: var(--cds-text-primary); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.spec.ts index c91d906ef221..22ae569fa38f 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.spec.ts @@ -11,6 +11,7 @@ import { PrometheusAlertService } from '~/app/shared/services/prometheus-alert.s import { PrometheusNotificationService } from '~/app/shared/services/prometheus-notification.service'; import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; import { SharedModule } from '~/app/shared/shared.module'; describe('NotificationsPageComponent', () => { @@ -33,7 +34,15 @@ describe('NotificationsPageComponent', () => { next: (value: CdNotification[]) => dataSourceSubject.next(value) }, remove: jasmine.createSpy('remove'), + removeById: jasmine.createSpy('removeById').and.returnValue(true), removeAll: jasmine.createSpy('removeAll'), + markAllAsRead: jasmine.createSpy('markAllAsRead').and.callFake(() => { + const notifications = dataSourceSubject.getValue(); + const updated = { ...readMapSubject.getValue() }; + notifications.forEach((n) => (updated[n.id] = true)); + readMapSubject.next(updated); + localStorage.setItem('cdNotificationsRead', JSON.stringify(updated)); + }), markAsRead: jasmine.createSpy('markAsRead').and.callFake((id: string) => { const current = readMapSubject.getValue(); if (!current[id]) { @@ -120,7 +129,8 @@ describe('NotificationsPageComponent', () => { { provide: PrometheusAlertService, useValue: mockPrometheusAlertService }, { provide: PrometheusNotificationService, useValue: mockPrometheusNotificationService }, { provide: AuthStorageService, useValue: mockAuthStorageService }, - { provide: Location, useValue: mockLocation } + { provide: Location, useValue: mockLocation }, + { provide: ActivatedRoute, useValue: { snapshot: { queryParams: {} } } } ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }).compileComponents(); @@ -177,7 +187,7 @@ describe('NotificationsPageComponent', () => { } as any; component.removeNotification(component.notifications()[0], mockEvent); expect(mockEvent.stopPropagation).toHaveBeenCalled(); - expect(notificationService.remove).toHaveBeenCalledWith(0); + expect(notificationService.removeById).toHaveBeenCalledWith('1'); }); it('should clear selection if removed notification was selected', () => { @@ -238,6 +248,15 @@ describe('NotificationsPageComponent', () => { expect(component.readMap()['2']).toBe(true); expect(component.readMap()['1']).toBeFalsy(); }); + + it('should mark all notifications as read', () => { + component.markAllAsRead(); + fixture.detectChanges(); + expect(notificationService.markAllAsRead).toHaveBeenCalled(); + expect(component.readMap()['1']).toBe(true); + expect(component.readMap()['2']).toBe(true); + expect(component.readMap()['3']).toBe(true); + }); }); describe('displayTitle and displayPreview', () => { @@ -290,6 +309,50 @@ describe('NotificationsPageComponent', () => { }); }); + describe('query param pre-selection', () => { + it('should pre-select notification from id query param', async () => { + const route = TestBed.inject(ActivatedRoute); + (route.snapshot.queryParams as any) = { id: '2' }; + + fixture = TestBed.createComponent(NotificationsPageComponent); + component = fixture.componentInstance; + dataSourceSubject.next(mockNotifications); + fixture.detectChanges(); + + expect(component.selectedNotificationID()).toBe('2'); + expect(notificationService.markAsRead).toHaveBeenCalledWith('2'); + }); + + it('should not pre-select if id does not match any notification', () => { + const route = TestBed.inject(ActivatedRoute); + (route.snapshot.queryParams as any) = { id: 'nonexistent' }; + + fixture = TestBed.createComponent(NotificationsPageComponent); + component = fixture.componentInstance; + dataSourceSubject.next(mockNotifications); + fixture.detectChanges(); + + expect(component.selectedNotificationID()).toBeNull(); + }); + + it('should not override manual selection on subsequent data emissions', () => { + const route = TestBed.inject(ActivatedRoute); + (route.snapshot.queryParams as any) = { id: '2' }; + + fixture = TestBed.createComponent(NotificationsPageComponent); + component = fixture.componentInstance; + dataSourceSubject.next(mockNotifications); + fixture.detectChanges(); + + component.onNotificationSelect(component.notifications()[0]); + expect(component.selectedNotificationID()).toBe('1'); + + dataSourceSubject.next(mockNotifications); + fixture.detectChanges(); + expect(component.selectedNotificationID()).toBe('1'); + }); + }); + it('should set up interval for Prometheus alerts when permissions exist', () => { mockAuthStorageService.getPermissions.and.returnValue({ prometheus: { read: true }, diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.ts index a250e2f15c18..b9c517eea79e 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.ts @@ -8,6 +8,7 @@ import { } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { Location } from '@angular/common'; +import { ActivatedRoute } from '@angular/router'; import { Subscription } from 'rxjs'; import { NotificationService } from '~/app/shared/services/notification.service'; import { CdNotification } from '~/app/shared/models/cd-notification'; @@ -38,6 +39,13 @@ export class NotificationsPageComponent implements OnInit, OnDestroy { this.notifications().find((n) => n.id === this.selectedNotificationID()) ); + hasNoNotifications = computed(() => this.notifications().length === 0); + + allRead = computed(() => { + const map = this.readMap(); + return this.notifications().every((n) => map[n.id]); + }); + private sub: Subscription; private interval: number; @@ -46,7 +54,8 @@ export class NotificationsPageComponent implements OnInit, OnDestroy { private prometheusAlertService: PrometheusAlertService, private prometheusNotificationService: PrometheusNotificationService, private authStorageService: AuthStorageService, - private location: Location + private location: Location, + private route: ActivatedRoute ) { this.readMap = toSignal(this.notificationService.readMap$, { initialValue: {} as Record @@ -71,6 +80,15 @@ export class NotificationsPageComponent implements OnInit, OnDestroy { }) ) ); + + const id = this.route.snapshot.queryParams['id']; + if (id && !this.selectedNotificationID()) { + const match = notifications.find((n) => n.id === id); + if (match) { + this.selectedNotificationID.set(id); + this.notificationService.markAsRead(id); + } + } }); } @@ -85,6 +103,10 @@ export class NotificationsPageComponent implements OnInit, OnDestroy { this.location.back(); } + markAllAsRead(): void { + this.notificationService.markAllAsRead(); + } + clearAll(): void { this.notificationService.removeAll(); this.selectedNotificationID.set(null); @@ -97,9 +119,7 @@ export class NotificationsPageComponent implements OnInit, OnDestroy { removeNotification(notification: DisplayNotification, event: MouseEvent): void { event.stopPropagation(); - const index = this.notifications().findIndex((n) => n.id === notification.id); - if (index > -1) { - this.notificationService.remove(index); + if (this.notificationService.removeById(notification.id)) { if (this.selectedNotificationID() === notification.id) { this.selectedNotificationID.set(null); } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notification-toast/notification-toast.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notification-toast/notification-toast.component.scss index 2afcf3d733eb..9c84ef47f177 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notification-toast/notification-toast.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notification-toast/notification-toast.component.scss @@ -1,7 +1,6 @@ @use '@carbon/styles/scss/theme' as *; @use '@carbon/styles/scss/spacing' as *; @use '@carbon/styles/scss/layer' as *; -@use '@carbon/styles/scss/type' as *; .cds--toast-notification-container { position: fixed; @@ -31,7 +30,20 @@ -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; - word-break: break-word; + overflow-wrap: break-word; + + .toast-message { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + + .toast-duplicate-count { + display: block; + color: $text-secondary; + margin-top: $spacing-02; + } } .cds--toast-notification__close-button { @@ -56,6 +68,16 @@ .toast-caption-container .date { flex-shrink: 0; } + + .toast-caption-container .toast-view-more { + color: $link-primary; + text-decoration: none; + margin-inline-start: auto; + + &:hover { + text-decoration: underline; + } + } } } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notification-toast/notification-toast.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notification-toast/notification-toast.component.spec.ts index 9c92162c0559..f39427f2009c 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notification-toast/notification-toast.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notification-toast/notification-toast.component.spec.ts @@ -1,5 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { Router } from '@angular/router'; import { of } from 'rxjs'; import { ToastContent } from 'carbon-components-angular'; @@ -11,10 +12,16 @@ describe('ToastComponent', () => { let component: ToastComponent; let fixture: ComponentFixture; let mockToasts: ToastContent[]; + let mockRouter: any; const mockNotificationService = { activeToasts$: of([]), - removeToast: jest.fn() + removeToast: jest.fn(), + clearAllToasts: jest.fn() + }; + + mockRouter = { + navigateByUrl: jest.fn() }; configureTestBed({ @@ -24,6 +31,10 @@ describe('ToastComponent', () => { { provide: NotificationService, useValue: mockNotificationService + }, + { + provide: Router, + useValue: mockRouter } ] }); @@ -68,4 +79,35 @@ describe('ToastComponent', () => { component.onToastClose(toast); expect(mockNotificationService.removeToast).toHaveBeenCalledWith(toast); }); + + describe('view more click', () => { + it('should navigate and clear toasts when view-more link is clicked', () => { + fixture.detectChanges(); + const link = document.createElement('a'); + link.classList.add('toast-view-more'); + link.setAttribute('href', '#/notifications?id=abc123'); + fixture.nativeElement.appendChild(link); + + const event = new MouseEvent('click', { bubbles: true }); + const preventDefaultSpy = jest.spyOn(event, 'preventDefault'); + link.dispatchEvent(event); + + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(mockRouter.navigateByUrl).toHaveBeenCalledWith('/notifications?id=abc123'); + expect(mockNotificationService.clearAllToasts).toHaveBeenCalled(); + }); + + it('should not navigate for non view-more clicks', () => { + fixture.detectChanges(); + mockRouter.navigateByUrl.mockClear(); + mockNotificationService.clearAllToasts.mockClear(); + + const span = document.createElement('span'); + fixture.nativeElement.appendChild(span); + span.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + expect(mockRouter.navigateByUrl).not.toHaveBeenCalled(); + expect(mockNotificationService.clearAllToasts).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notification-toast/notification-toast.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notification-toast/notification-toast.component.ts index c9853281575e..6c5446cfea2e 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notification-toast/notification-toast.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notification-toast/notification-toast.component.ts @@ -1,5 +1,12 @@ -import { Component, OnInit } from '@angular/core'; +import { + Component, + OnInit, + AfterViewChecked, + HostListener, + ElementRef +} from '@angular/core'; import { animate, style, transition, trigger } from '@angular/animations'; +import { Router } from '@angular/router'; import { Observable } from 'rxjs'; import { ToastContent } from 'carbon-components-angular'; import { NotificationService } from '../../services/notification.service'; @@ -30,15 +37,43 @@ import { NotificationService } from '../../services/notification.service'; ], standalone: false }) -export class ToastComponent implements OnInit { +export class ToastComponent implements OnInit, AfterViewChecked { activeToasts$: Observable; - constructor(private notificationService: NotificationService) {} + constructor( + private notificationService: NotificationService, + private router: Router, + private el: ElementRef + ) {} ngOnInit() { this.activeToasts$ = this.notificationService.activeToasts$; } + ngAfterViewChecked() { + const toasts = this.el.nativeElement.querySelectorAll('cds-toast'); + toasts.forEach((toast: HTMLElement) => { + const subtitle = toast.querySelector('.cds--toast-notification__subtitle'); + const viewMore = toast.querySelector('.toast-view-more') as HTMLElement; + if (!subtitle || !viewMore) return; + const isTruncated = subtitle.scrollHeight > subtitle.clientHeight; + viewMore.style.display = isTruncated ? '' : 'none'; + }); + } + + @HostListener('click', ['$event']) + onViewMoreClick(event: Event) { + const target = event.target as HTMLElement; + if (target.classList.contains('toast-view-more')) { + event.preventDefault(); + const href = target.getAttribute('href'); + if (href) { + this.router.navigateByUrl(href.replace('#', '')); + } + this.notificationService.clearAllToasts(); + } + } + onToastClose(toast: ToastContent) { this.notificationService.removeToast(toast); } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-notification.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-notification.ts index 2192f97cf6ea..089539e2b5c4 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-notification.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-notification.ts @@ -52,6 +52,7 @@ export class CdNotification extends CdNotificationConfig { iconClass: string; duration: number; borderClass: string; + occurrences = 1; alertSilenced = false; silenceId?: string; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.ts index 9da0326b4eee..98dc131c118b 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.ts @@ -26,6 +26,8 @@ export class NotificationService { private readonly MAX_NOTIFICATIONS = 10; private readonly SHOW_DELAY = 10; private readonly QUEUE_DELAY = 500; + private readonly ERROR_TOAST_DURATION = 10000; + private readonly DEFAULT_TOAST_DURATION = 5000; private readonly LOCAL_STORAGE_KEY = 'cdNotifications'; private readonly LOCAL_STORAGE_MUTE_KEY = 'cdNotificationsMuted'; private readonly LOCAL_STORAGE_READ_KEY = 'cdNotificationsRead'; @@ -129,7 +131,19 @@ export class NotificationService { * Saving a shown notification in local storage */ save(notification: CdNotification) { - const notifications = [notification, ...this.dataSource.getValue()]; + const current = this.dataSource.getValue(); + const existing = current.find( + (n) => n.title === notification.title && n.type === notification.type + ); + + let notifications: CdNotification[]; + if (existing) { + existing.occurrences = (existing.occurrences || 1) + 1; + existing.timestamp = notification.timestamp; + notifications = [...current]; + } else { + notifications = [notification, ...current]; + } const limited = notifications .sort((a, b) => (a.timestamp > b.timestamp ? -1 : 1)) @@ -151,6 +165,14 @@ export class NotificationService { this._persistNotifications(notifications); } + removeById(id: string): boolean { + const notifications = this.dataSource.getValue(); + const index = notifications.findIndex((n) => n.id === id); + if (index === -1) return false; + this.remove(index); + return true; + } + /** * Removes all current saved notifications from storage (and any appearing toasts) */ @@ -160,7 +182,7 @@ export class NotificationService { this.readMapSource.next({}); this.dataSource.next([]); this.hasUnreadSource.next(false); - this._clearAllToasts(); + this.clearAllToasts(); } /** @@ -261,21 +283,39 @@ export class NotificationService { const carbonType = this.NOTIFICATION_TYPE_MAP[notification.type] || 'info'; const lowContrast = notification.options?.lowContrast || false; + const existing = this.activeToasts.find( + (t) => t.title === notification.title && t.type === carbonType + ); + if (existing) { + existing.duplicateCount = (existing.duplicateCount || 1) + 1; + const count = existing.duplicateCount - 1; + existing.subtitle = `${existing.originalSubtitle}(+${count} more)`; + existing.caption = this._renderTimeAndApplicationHtml(notification); + this.activeToastsSource.next([...this.activeToasts]); + return; + } + + const subtitle = notification.message || ''; const toast: ToastContent = { title: notification.title, - subtitle: notification.message || '', + subtitle, caption: this._renderTimeAndApplicationHtml(notification), type: carbonType, lowContrast: lowContrast, showClose: true, - duration: notification.options?.timeOut || 5000 + duration: + notification.options?.timeOut || + (notification.type === NotificationType.error + ? this.ERROR_TOAST_DURATION + : this.DEFAULT_TOAST_DURATION), + notificationId: notification.id, + duplicateCount: 1, + originalSubtitle: subtitle }; - // Add new toast to the beginning of the array this.activeToasts.unshift(toast); this.activeToastsSource.next(this.activeToasts); - // Handle duration-based auto-dismissal if (toast.duration && toast.duration > 0) { this.ngZone.runOutsideAngular(() => { setTimeout(() => { @@ -288,14 +328,13 @@ export class NotificationService { } private _renderTimeAndApplicationHtml(notification: CdNotification): string { - let html = `
    - ${this.cdDatePipe.transform(notification.timestamp)}`; - - html += '
    '; - return html; + return `
    + ${this.cdDatePipe.transform(notification.timestamp)} + View more +
    `; } - private _clearAllToasts() { + clearAllToasts() { this.activeToasts = []; this.activeToastsSource.next(this.activeToasts); } @@ -383,4 +422,13 @@ export class NotificationService { this._persistReadMap(updated); this._recomputeHasUnread(this.dataSource.getValue()); } + + markAllAsRead() { + const notifications = this.dataSource.getValue(); + const updated = { ...this.readMapSource.getValue() }; + notifications.forEach((n) => (updated[n.id] = true)); + this.readMapSource.next(updated); + this._persistReadMap(updated); + this._recomputeHasUnread(notifications); + } }