]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Add View more link to toasts
authorAfreen Misbah <afreen@ibm.com>
Tue, 14 Jul 2026 19:06:45 +0000 (00:36 +0530)
committerAfreen Misbah <afreen@ibm.com>
Tue, 21 Jul 2026 14:08:51 +0000 (19:38 +0530)
- 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 <afreen@ibm.com>
15 files changed:
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.html
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.scss
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.ts
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-item/notification-item.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-item/notification-item.component.ts
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.html
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.scss
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/notification-toast/notification-toast.component.scss
src/pybind/mgr/dashboard/frontend/src/app/shared/components/notification-toast/notification-toast.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/notification-toast/notification-toast.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-notification.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.ts

index 27a30ab40ad0745afdeaa0bb20135215a69d60eb..08a67d7e9f723dc68d628f68bb7bd3d4001ae621 100644 (file)
   >
     <div class="notification-wrapper">
       <div
-        class="notification-item"
+        class="notification-item notification-item--clickable"
         data-testid="notification-item"
+        role="button"
+        tabindex="0"
+        (click)="navigateToNotification(notification)"
+        (keydown.enter)="navigateToNotification(notification)"
       >
         <cd-notification-item
           [type]="notification.type"
index 2d2031d7a726161ff068f98d46917952bd4f50f6..28569607d15ec8350d39d0a577de5a58731882b3 100644 (file)
@@ -5,13 +5,6 @@ cd-notification-area {
   min-block-size: 0;
 }
 
-:host {
-  flex: 1;
-  overflow-y: auto;
-  overflow-x: hidden;
-  min-block-size: 0;
-}
-
 .notification-section-heading {
   margin: 0;
   color: var(--cds-text-primary);
@@ -47,25 +40,7 @@ cd-notification-area {
   color: var(--cds-text-primary);
   line-height: 1.25;
   display: block;
-<<<<<<< HEAD
   overflow-wrap: break-word;
-=======
-  word-break: break-word;
-}
-
-.notification-message {
-  @include type.type-style('body-short-01');
-
-  color: $text-helper;
-  margin: 0;
-  line-height: 1.4;
-  margin-top: -$spacing-01;
-  display: -webkit-box;
-  -webkit-line-clamp: 2;
-  -webkit-box-orient: vertical;
-  overflow: hidden;
-  word-break: break-word;
->>>>>>> 8e9c61bcc14 (mgr/dashboard: Fix the footer - view all button css in notification panel)
 }
 
 .notification-empty {
index 36298f4b109f5ed526fe55215516c1add1d89317..1f30eb0e4c4410f7a9a7da3ff8ff94f71f436fb8 100644 (file)
@@ -49,6 +49,7 @@ describe('NotificationAreaComponent', () => {
     mockDataSource = new BehaviorSubject<CdNotification[]>(mockNotifications);
     const spy = {
       remove: jasmine.createSpy('remove'),
+      removeById: jasmine.createSpy('removeById').and.returnValue(true),
       dataSource: mockDataSource,
       data$: mockDataSource.asObservable(),
       getNotificationsSnapshot: () => mockDataSource.getValue()
index cd323d29d92adf752f4ade67529d7270a75cffc7..d27ee54ddb8354e770abecb78a8b3bb01bf1dd81 100644 (file)
@@ -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);
+  }
 }
index 12d17660aff141f9dfd87ebd078ef2347e2de7d6..cf749081f866913b2195fd1c1ad0fbf470727ce2 100644 (file)
@@ -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();
   });
 });
index 7e0d01953d14121386dc622469be9110f443c6e6..04aabf9e61f02edbdac21ad7fa50ea801ba80358 100644 (file)
@@ -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);
     }
   }
index 1945ac7474fce3261ad271a3226a94b5fa3be331..ad19191f33910b13961bbe7041a0998b9e4c66ca 100644 (file)
     >
       Notifications
     </h4>
-    <a
-      cdsLink
-      class="notifications-page__header-action"
-      tabindex="0"
-      (click)="clearAll()"
-      (keydown.enter)="clearAll()"
-      i18n
+    <cds-overflow-menu
+      class="notifications-page__header-menu"
+      [flip]="true"
+      description=""
+      i18n-description
     >
-      Clear all
-    </a>
+      <li
+        class="cds--overflow-menu-options__option"
+        [class.cds--overflow-menu-options__option--disabled]="allRead() || hasNoNotifications()"
+      >
+        <button
+          class="cds--overflow-menu-options__btn"
+          type="button"
+          [disabled]="allRead() || hasNoNotifications()"
+          (click)="markAllAsRead()"
+          i18n
+        >
+          Mark all as read
+        </button>
+      </li>
+      <li
+        class="cds--overflow-menu-options__option"
+        [class.cds--overflow-menu-options__option--disabled]="hasNoNotifications()"
+      >
+        <button
+          class="cds--overflow-menu-options__btn"
+          type="button"
+          [disabled]="hasNoNotifications()"
+          (click)="clearAll()"
+          i18n
+        >
+          Clear all
+        </button>
+      </li>
+    </cds-overflow-menu>
   </header>
 
   <div class="notifications-page__body">
           ></cd-notification-item>
         </div>
         <div class="notifications-page__detail-body cds-mt-5">
+          @if (selected.occurrences > 1) {
+            <p class="notifications-page__occurrences cds--type-label-01" i18n>
+              Occurrences: {{ selected.occurrences }}
+            </p>
+          }
           <p class="notifications-page__detail-text cds--type-body-01">
             {{ selected.displayPreview }}
           </p>
index 4d5d87697f74230b58e3f572fdd79b0ad15fa6d0..fea934ca1b26983406ded11ac1c11486d383d7be 100644 (file)
     display: inline-flex;
     align-items: center;
     gap: var(--cds-spacing-02);
+  }
 
-    &:last-child {
-      justify-self: end;
-    }
+  &__header-menu {
+    justify-self: end;
   }
 
   &__header-title {
     }
   }
 
+  &__occurrences {
+    margin: 0 0 var(--cds-spacing-03) 0;
+    color: var(--cds-text-secondary);
+  }
+
   &__detail-text {
     margin: 0;
     color: var(--cds-text-primary);
index c91d906ef221b2b98ded18a6d0b4e632c01757dd..22ae569fa38fc96fadcace25dcb1c1b82407eb2d 100644 (file)
@@ -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 },
index a250e2f15c185f5d3a6099889060b597cddc9594..b9c517eea79ec49964e6b660ead9832b9900dbb9 100644 (file)
@@ -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<string, boolean>
@@ -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);
       }
index 2afcf3d733eb3c49e2e4786bf4352e51693e069b..9c84ef47f1777f2dc53a9ea7a502077eb18e879d 100644 (file)
@@ -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;
         -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 {
       .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;
+        }
+      }
     }
   }
 }
index 9c92162c0559796d14a7d35a94a603de8081d381..f39427f2009c14a82840cfeb7dec38c511dd567a 100644 (file)
@@ -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<ToastComponent>;
   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();
+    });
+  });
 });
index c9853281575e21f923bb62368739fed95f00a248..6c5446cfea2e2f7373525f0c4275d6a19e743b3c 100644 (file)
@@ -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<ToastContent[]>;
 
-  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);
   }
index 2192f97cf6eab0a4e1feff76c270c64c80ac9f26..089539e2b5c41ec12621cd7932385910b86a992c 100644 (file)
@@ -52,6 +52,7 @@ export class CdNotification extends CdNotificationConfig {
   iconClass: string;
   duration: number;
   borderClass: string;
+  occurrences = 1;
   alertSilenced = false;
   silenceId?: string;
 
index 9da0326b4eeef578225d786080ecb00dc0560461..98dc131c118b9d551e457a9f41a482d43c806fc3 100644 (file)
@@ -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 = `<span class="toast-message">${existing.originalSubtitle}</span><span class="toast-duplicate-count">(+${count} more)</span>`;
+      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 = `<div class="toast-caption-container">
-      <small class="date">${this.cdDatePipe.transform(notification.timestamp)}</small>`;
-
-    html += '</div>';
-    return html;
+    return `<div class="toast-caption-container">
+      <small class="date">${this.cdDatePipe.transform(notification.timestamp)}</small>
+      <a class="toast-view-more cds--type-label-01" href="#/notifications?id=${notification.id}" i18n>View more</a>
+    </div>`;
   }
 
-  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);
+  }
 }