]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: redesign notifications page with master-detail layout 70191/head
authorAfreen Misbah <afreen@ibm.com>
Tue, 14 Jul 2026 12:09:50 +0000 (17:39 +0530)
committerAfreen Misbah <afreen@ibm.com>
Fri, 17 Jul 2026 21:04:55 +0000 (02:34 +0530)
-  revamped UX
-  shows unread notifications
-  added back button
-  added clear all, and single notification delete

Fixes: https://tracker.ceph.com/issues/78209
Signed-off-by: Afreen Misbah <afreen@ibm.com>
18 files changed:
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation.module.ts
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.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-item/notification-item.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-item/notification-item.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-item/notification-item.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-panel/notification-panel.component.html
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/icon/icon.component.scss
src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/enum/notification-type.enum.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.ts

index 67e1ea0b6a2b9e293caefc617f12ee0e034b3d88..93f2fe65ed4823331eb55cda3b110b041268980f 100644 (file)
@@ -19,7 +19,8 @@ import {
   ProgressBarModule,
   StructuredListModule,
   SearchModule,
-  ModalModule
+  ModalModule,
+  LinkModule
 } from 'carbon-components-angular';
 
 import { AppRoutingModule } from '~/app/app-routing.module';
@@ -67,11 +68,14 @@ import IbmCloudBareMetalServerIcon from '@carbon/icons/es/ibm-cloud--bare-metal-
 import IbmCloudDedicatedHostIcon from '@carbon/icons/es/ibm-cloud--dedicated-host/20';
 import VmdkDiskIcon from '@carbon/icons/es/vmdk-disk/20';
 
+import ArrowLeftIcon from '@carbon/icons/es/arrow--left/16';
+import ChevronRightIcon from '@carbon/icons/es/chevron--right/16';
 import CloseIcon from '@carbon/icons/es/close/16';
 import { NotificationPanelComponent } from './notification-panel/notification-panel/notification-panel.component';
 import { NotificationHeaderComponent } from './notification-panel/notification-header/notification-header.component';
 import { NotificationAreaComponent } from './notification-panel/notification-area/notification-area.component';
 import { NotificationFooterComponent } from './notification-panel/notification-footer/notification-footer.component';
+import { NotificationItemComponent } from './notification-panel/notification-item/notification-item.component';
 
 @NgModule({
   imports: [
@@ -96,7 +100,8 @@ import { NotificationFooterComponent } from './notification-panel/notification-f
     ProgressBarModule,
     StructuredListModule,
     SearchModule,
-    ModalModule
+    ModalModule,
+    LinkModule
   ],
   declarations: [
     AboutComponent,
@@ -108,6 +113,7 @@ import { NotificationFooterComponent } from './notification-panel/notification-f
     NotificationHeaderComponent,
     NotificationAreaComponent,
     NotificationFooterComponent,
+    NotificationItemComponent,
     NotificationsPageComponent,
     DashboardHelpComponent,
     AdministrationComponent,
@@ -156,6 +162,8 @@ export class NavigationModule {
       CircleDashIcon20,
       CheckmarkOutlineIcon20,
       ArrowRightIcon,
+      ArrowLeftIcon,
+      ChevronRightIcon,
       VmdkDiskIcon
     ]);
   }
index 8cc60cc80c27e4462a7fbff720ca95bf74310466..27a30ab40ad0745afdeaa0bb20135215a69d60eb 100644 (file)
@@ -1,7 +1,7 @@
 <div class="notification-area">
   @if (executingTasks.length > 0) {
     <div
-      class="notification-section-heading"
+      class="notification-section-heading cds--type-heading-compact-01"
       i18n
     >
       Running tasks
                 {{ task.begin_time | relativeDate }}
               </span>
 
-              <span class="task-progress"> {{ task.progress || 0 }} % </span>
+              <span class="task-progress cds--type-label-01"> {{ task.progress || 0 }} % </span>
             </div>
           </div>
         </div>
 
-        <div class="notification-divider"></div>
+        <div class="border-subtle-bottom"></div>
       </div>
     }
   }
         class="notification-item"
         data-testid="notification-item"
       >
-        <cd-icon
-          id="notification-icon"
-          [type]="notificationIconMap[notification.type] || notificationIconMap['default']"
+        <cd-notification-item
+          [type]="notification.type"
+          [title]="notification.title"
+          [timestamp]="notification.timestamp"
+          [message]="notification.message"
+          [notificationId]="notification.id"
         >
-        </cd-icon>
-
-        <div class="notification-content">
-          <div class="notification-timestamp">
-            {{ notification.timestamp | relativeDate }}
-          </div>
-          <div class="notification-title">
-            {{ notification.title }}
-          </div>
-          <div
-            class="notification-message"
-            [innerHTML]="notification.message | sanitizeHtml"
-          ></div>
-        </div>
-
-        <button
-          cdsButton="ghost"
-          size="sm"
-          class="notification-close"
-          (click)="removeNotification(notification, $event)"
-          type="button"
-        >
-          <cd-icon type="destroy"></cd-icon>
-        </button>
+        </cd-notification-item>
       </div>
 
       @if (!last) {
-        <div class="notification-divider"></div>
+        <div class="border-subtle-bottom"></div>
       }
     </div>
   </ng-template>
 
   @if (todayNotifications.length > 0) {
     <div
-      class="notification-section-heading"
+      class="notification-section-heading cds--type-heading-compact-01"
       i18n
     >
       Today
 
   @if (previousNotifications.length > 0) {
     <div
-      class="notification-section-heading"
+      class="notification-section-heading cds--type-heading-compact-01"
       i18n
     >
       Previous
   }
 
   @if (todayNotifications.length === 0 && previousNotifications.length === 0) {
-    <div class="notification-empty">
+    <div class="notification-empty cds--type-body-short-01">
       <div i18n>No notifications</div>
     </div>
   }
index 86bd9d74e52f94622dac26c04958a94eb4f77220..28569607d15ec8350d39d0a577de5a58731882b3 100644 (file)
@@ -1,9 +1,4 @@
-@use './src/styles/defaults' as *;
-@use '@carbon/styles/scss/theme' as *;
-@use '@carbon/styles/scss/spacing' as *;
-@use '@carbon/type';
-
-:host {
+cd-notification-area {
   flex: 1;
   overflow-y: auto;
   overflow-x: hidden;
 }
 
 .notification-section-heading {
-  @include type.type-style('heading-compact-01');
-
   margin: 0;
-  color: $text-primary;
-  padding: $spacing-05 $spacing-05 $spacing-03;
-  background-color: $layer-01;
+  color: var(--cds-text-primary);
+  padding: var(--cds-spacing-05) var(--cds-spacing-05) var(--cds-spacing-03);
+  background-color: var(--cds-layer-01);
   position: sticky;
   z-index: 2;
   top: 0;
 }
 
 .task-progress {
-  @include type.type-style('label-01');
-
   color: var(--cds-text-secondary);
   text-align: right;
 }
 
 .notification-timestamp {
-  @include type.type-style('label-01');
-
-  color: $text-secondary;
+  color: var(--cds-text-secondary);
   line-height: 1;
   margin-top: 0;
   display: block;
 }
 
 .notification-title {
-  @include type.type-style('body-short-01');
-
   margin: 0;
-  color: $text-primary;
+  color: var(--cds-text-primary);
   line-height: 1.25;
   display: block;
   overflow-wrap: 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;
-  overflow-wrap: break-word;
-}
-
 .notification-empty {
   margin: 0;
-  padding: $spacing-05;
-  color: $text-secondary;
+  padding: var(--cds-spacing-05);
+  color: var(--cds-text-secondary);
   text-align: center;
-
-  @include type.type-style('body-short-01');
 }
 
 .notification-wrapper {
-  padding: 0 $spacing-05;
+  padding: 0 var(--cds-spacing-05);
   position: relative;
   z-index: 1;
-  background-color: $layer-01;
+  background-color: var(--cds-layer-01);
 
   &:last-child {
-    padding-bottom: $spacing-05;
+    padding-bottom: var(--cds-spacing-05);
   }
 }
 
 .notification-item {
   display: flex;
-  gap: $spacing-05;
-  padding: $spacing-03 0;
+  gap: var(--cds-spacing-05);
+  padding: var(--cds-spacing-03) 0;
   align-items: flex-start;
   position: relative;
 
   > cd-icon {
-    margin-top: -$spacing-03;
+    margin-top: calc(-1 * var(--cds-spacing-03));
+  }
+
+  cd-notification-item {
+    flex: 1;
+    min-width: 0;
   }
 
-  .notification-close {
-    position: absolute;
-    right: 0;
-    top: $spacing-03;
-    padding: $spacing-02;
-    min-height: 0;
-    color: $text-helper;
+  .cd-notification-item__delete {
     opacity: 0;
     transition: opacity 0.2s ease-in-out;
-
-    &:hover {
-      background-color: $layer-hover;
-    }
   }
 
-  &:hover {
-    .notification-close {
-      opacity: 1;
-    }
+  &:hover .cd-notification-item__delete {
+    opacity: 1;
   }
 }
 
   min-width: 0;
   display: flex;
   flex-direction: column;
-  gap: $spacing-02;
-  padding-right: $spacing-06;
-}
-
-.notification-divider {
-  border-bottom: 1px solid $border-subtle-01;
+  gap: var(--cds-spacing-02);
+  padding-right: var(--cds-spacing-06);
 }
 
 .notification-area {
-  background-color: $layer-01;
+  --cd-notification-item-icon-offset: calc(-1 * var(--cds-spacing-02));
+
+  background-color: var(--cds-layer-01);
 }
index 6731eed237e711beddb5026d526ba507b367473e..36298f4b109f5ed526fe55215516c1add1d89317 100644 (file)
@@ -4,6 +4,7 @@ import { BehaviorSubject } from 'rxjs';
 import { NoopAnimationsModule } from '@angular/platform-browser/animations';
 
 import { NotificationAreaComponent } from './notification-area.component';
+import { NotificationItemComponent } from '../notification-item/notification-item.component';
 import { NotificationService } from '../../../../shared/services/notification.service';
 import { CdNotification, CdNotificationConfig } from '../../../../shared/models/cd-notification';
 import { NotificationType } from '../../../../shared/enum/notification-type.enum';
@@ -13,7 +14,6 @@ import { HttpClientTestingModule } from '@angular/common/http/testing';
 describe('NotificationAreaComponent', () => {
   let component: NotificationAreaComponent;
   let fixture: ComponentFixture<NotificationAreaComponent>;
-  let notificationService: any;
   let mockDataSource: BehaviorSubject<CdNotification[]>;
 
   const today = new Date();
@@ -42,7 +42,7 @@ describe('NotificationAreaComponent', () => {
 
   configureTestBed({
     imports: [SharedModule, NoopAnimationsModule],
-    declarations: [NotificationAreaComponent]
+    declarations: [NotificationAreaComponent, NotificationItemComponent]
   });
 
   beforeEach(() => {
@@ -57,7 +57,6 @@ describe('NotificationAreaComponent', () => {
     TestBed.overrideProvider(NotificationService, { useValue: spy });
     fixture = TestBed.createComponent(NotificationAreaComponent);
     component = fixture.componentInstance;
-    notificationService = TestBed.inject(NotificationService);
     fixture.detectChanges();
   });
 
@@ -81,19 +80,6 @@ describe('NotificationAreaComponent', () => {
     expect(emptyElement.nativeElement.textContent).toContain('No notifications');
   });
 
-  it('should remove notification when close button is clicked', () => {
-    const notification = mockNotifications[0];
-    const event = new MouseEvent('click');
-    spyOn(event, 'stopPropagation');
-    spyOn(event, 'preventDefault');
-
-    component.removeNotification(notification, event);
-
-    expect(event.stopPropagation).toHaveBeenCalled();
-    expect(event.preventDefault).toHaveBeenCalled();
-    expect(notificationService.remove).toHaveBeenCalledWith(0);
-  });
-
   it('should unsubscribe from notification service on destroy', () => {
     const subSpy = spyOn(component['subs'], 'unsubscribe');
     component.ngOnDestroy();
@@ -106,30 +92,31 @@ describe('NotificationAreaComponent', () => {
 
     const firstNotification = notificationElements[0];
     expect(
-      firstNotification.query(By.css('.notification-title')).nativeElement.textContent
+      firstNotification.query(By.css('.cd-notification-item__title')).nativeElement.textContent
     ).toContain('Success Today');
     expect(
-      firstNotification.query(By.css('.notification-message')).nativeElement.textContent
+      firstNotification.query(By.css('.cd-notification-item__message')).nativeElement.textContent
     ).toContain('message');
 
-    const iconElement = firstNotification.query(By.css('#notification-icon'));
-    expect(iconElement).toBeTruthy();
+    const itemElement = firstNotification.query(By.css('cd-notification-item'));
+    expect(itemElement).toBeTruthy();
   });
 
   it('should display notification timestamps with relative date pipe', () => {
-    const timestampElements = fixture.debugElement.queryAll(By.css('.notification-timestamp'));
+    const timestampElements = fixture.debugElement.queryAll(
+      By.css('.cd-notification-item__timestamp')
+    );
     expect(timestampElements.length).toBe(2);
     expect(timestampElements[0].nativeElement.textContent).toBeTruthy();
     expect(timestampElements[1].nativeElement.textContent).toBeTruthy();
   });
 
   it('should render notification icons with correct types', () => {
-    const iconElements = fixture.debugElement.queryAll(By.css('#notification-icon'));
-    expect(iconElements.length).toBe(2);
+    const items = fixture.debugElement.queryAll(By.css('cd-notification-item'));
+    expect(items.length).toBe(2);
 
-    // Check that icons have the correct type attribute
-    expect(iconElements[0].attributes['ng-reflect-type']).toBe('success');
-    expect(iconElements[1].attributes['ng-reflect-type']).toBe('error');
+    expect(items[0].attributes['ng-reflect-type']).toBe('2');
+    expect(items[1].attributes['ng-reflect-type']).toBe('0');
   });
 
   it('should render notification dividers between items', () => {
@@ -137,23 +124,14 @@ describe('NotificationAreaComponent', () => {
     expect(dividerElements.length).toBe(0);
   });
 
-  it('should render close buttons for each notification', () => {
-    const closeButtons = fixture.debugElement.queryAll(By.css('.notification-close'));
-    expect(closeButtons.length).toBe(2);
-
-    const closeIcons = fixture.debugElement.queryAll(By.css('.notification-close cd-icon'));
-    expect(closeIcons.length).toBe(2);
-    expect(closeIcons[0].attributes['ng-reflect-type']).toBe('destroy');
-  });
-
   it('should render notification content with proper structure', () => {
-    const contentElements = fixture.debugElement.queryAll(By.css('.notification-content'));
+    const contentElements = fixture.debugElement.queryAll(By.css('.cd-notification-item__content'));
     expect(contentElements.length).toBe(2);
 
     contentElements.forEach((content) => {
-      expect(content.query(By.css('.notification-timestamp'))).toBeTruthy();
-      expect(content.query(By.css('.notification-title'))).toBeTruthy();
-      expect(content.query(By.css('.notification-message'))).toBeTruthy();
+      expect(content.query(By.css('.cd-notification-item__timestamp'))).toBeTruthy();
+      expect(content.query(By.css('.cd-notification-item__title'))).toBeTruthy();
+      expect(content.query(By.css('.cd-notification-item__message'))).toBeTruthy();
     });
   });
 
@@ -173,11 +151,9 @@ describe('NotificationAreaComponent', () => {
     expect(headings[1].nativeElement.textContent).toContain('Previous');
   });
 
-  it('should handle notification icon mapping correctly', () => {
-    expect(component.notificationIconMap[NotificationType.success]).toBe('success');
-    expect(component.notificationIconMap[NotificationType.error]).toBe('error');
-    expect(component.notificationIconMap[NotificationType.info]).toBe('infoCircle');
-    expect(component.notificationIconMap[NotificationType.warning]).toBe('warning');
+  it('should render cd-notification-item for each notification', () => {
+    const items = fixture.debugElement.queryAll(By.css('cd-notification-item'));
+    expect(items.length).toBe(2);
   });
 
   it('should handle notifications with different types', () => {
@@ -260,38 +236,4 @@ describe('NotificationAreaComponent', () => {
     expect(headings.length).toBe(1);
     expect(headings[0].nativeElement.textContent).toContain('Previous');
   });
-
-  it('should find correct notification index when removing', () => {
-    const notification = mockNotifications[0];
-    const event = new MouseEvent('click');
-    spyOn(event, 'stopPropagation');
-    spyOn(event, 'preventDefault');
-
-    spyOn(notificationService['dataSource'], 'getValue').and.returnValue(mockNotifications);
-
-    component.removeNotification(notification, event);
-
-    expect(event.stopPropagation).toHaveBeenCalled();
-    expect(event.preventDefault).toHaveBeenCalled();
-    expect(notificationService.remove).toHaveBeenCalledWith(0);
-  });
-
-  it('should handle remove notification when index not found', () => {
-    const notification = createNotification(
-      NotificationType.info,
-      'Not Found',
-      today.toISOString()
-    );
-    const event = new MouseEvent('click');
-    spyOn(event, 'stopPropagation');
-    spyOn(event, 'preventDefault');
-
-    spyOn(notificationService['dataSource'], 'getValue').and.returnValue([]);
-
-    component.removeNotification(notification, event);
-
-    expect(event.stopPropagation).toHaveBeenCalled();
-    expect(event.preventDefault).toHaveBeenCalled();
-    expect(notificationService.remove).not.toHaveBeenCalled();
-  });
 });
index a767f6be301aa32192c055bbed9a1a6a93e85348..cd323d29d92adf752f4ade67529d7270a75cffc7 100644 (file)
@@ -1,8 +1,7 @@
-import { Component, OnInit, OnDestroy } from '@angular/core';
+import { Component, OnInit, OnDestroy, ViewEncapsulation } from '@angular/core';
 import { Subscription } from 'rxjs';
 import { NotificationService } from '../../../../shared/services/notification.service';
 import { CdNotification } from '../../../../shared/models/cd-notification';
-import { NotificationType } from '../../../../shared/enum/notification-type.enum';
 import { SummaryService } from '~/app/shared/services/summary.service';
 import { Mutex } from 'async-mutex';
 import _ from 'lodash';
@@ -16,6 +15,7 @@ import { Icons } from '~/app/shared/enum/icons.enum';
   selector: 'cd-notification-area',
   templateUrl: './notification-area.component.html',
   styleUrls: ['./notification-area.component.scss'],
+  encapsulation: ViewEncapsulation.None,
   standalone: false
 })
 export class NotificationAreaComponent implements OnInit, OnDestroy {
@@ -27,14 +27,6 @@ export class NotificationAreaComponent implements OnInit, OnDestroy {
   icons = Icons;
   executingTasks: ExecutingTask[] = [];
 
-  readonly notificationIconMap = {
-    [NotificationType.success]: 'success',
-    [NotificationType.error]: 'error',
-    [NotificationType.info]: 'infoCircle',
-    [NotificationType.warning]: 'warning',
-    default: 'infoCircle'
-  } as const;
-
   constructor(
     private notificationService: NotificationService,
     private summaryService: SummaryService,
@@ -101,19 +93,4 @@ export class NotificationAreaComponent implements OnInit, OnDestroy {
   ngOnDestroy(): void {
     this.subs.unsubscribe();
   }
-
-  removeNotification(notification: CdNotification, event: MouseEvent) {
-    // Stop event propagation to prevent panel closing
-    event.stopPropagation();
-    event.preventDefault();
-
-    // Get the notification index from the service's data
-    const notifications = this.notificationService.getNotificationsSnapshot();
-    const index = notifications.findIndex((n) => n.id === notification.id);
-
-    if (index > -1) {
-      // Remove the notification through the service
-      this.notificationService.remove(index);
-    }
-  }
 }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-item/notification-item.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-item/notification-item.component.html
new file mode 100644 (file)
index 0000000..729361e
--- /dev/null
@@ -0,0 +1,80 @@
+<!-- ICON / UNREAD DOT -->
+@if (showChevron) {
+  <span
+    class="cd-notification-item__unread-dot"
+    [class.cd-notification-item__unread-dot--visible]="showUnread"
+  ></span>
+} @else {
+  <cd-icon [type]="iconMap[type] || iconMap['default']"></cd-icon>
+}
+<!-- CONTENT -->
+<div class="cd-notification-item__content">
+  <!-- RELATIVE TIMESTAMP -->
+  @if (timestamp) {
+    <span class="cd-notification-item__timestamp cds--type-label-01">
+      {{ timestamp | relativeDate: true }}
+    </span>
+  }
+  <!-- NOTIFICATION TITLE -->
+  @if (title) {
+    <span
+      class="cd-notification-item__title cds--type-body-short-01"
+      [class.cd-notification-item__title--unread]="showUnread"
+      >{{ title }}</span
+    >
+  }
+  <!-- NOTIFICATION MESSAGE -->
+  @if (message) {
+    @if (!showChevron) {
+      <span
+        class="cd-notification-item__message cds--type-body-short-01"
+        [innerHTML]="message | sanitizeHtml"
+      ></span>
+    } @else {
+      <p class="cd-notification-item__preview cds--type-label-01">
+        {{ message }}
+      </p>
+    }
+  }
+</div>
+<!-- RIGHT SECTION -->
+<div class="cd-notification-item__right">
+  <!-- APPLICATION BADGE -->
+  @if (application) {
+    <span class="cd-notification-item__app cds--type-label-01">
+      <img
+        [src]="
+          application === NotificationApplication.Prometheus
+            ? 'assets/prometheus_logo.svg'
+            : 'assets/Ceph_Logo.svg'
+        "
+        [alt]="application"
+        class="cd-notification-item__app-icon"
+      />
+      {{ timestamp | date: 'MMM d, y, h:mm a' }}
+    </span>
+  }
+  <!-- ACTION -->
+  @if (showChevron) {
+    <span class="cd-notification-item__chevron">
+      <cd-icon
+        type="angleDoubleRight"
+        [useDefault]="true"
+      ></cd-icon>
+    </span>
+  } @else {
+    <cds-icon-button
+      kind="ghost"
+      size="sm"
+      class="cd-notification-item__delete"
+      (click)="onDelete($event)"
+      i18n-title
+      title="Delete notification"
+    >
+      <cd-icon
+        type="destroy"
+        [useDefault]="true"
+      ></cd-icon>
+    </cds-icon-button>
+  }
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-item/notification-item.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-item/notification-item.component.scss
new file mode 100644 (file)
index 0000000..94d7b75
--- /dev/null
@@ -0,0 +1,88 @@
+cd-notification-item {
+  display: flex;
+  gap: var(--cds-spacing-05);
+  align-items: flex-start;
+
+  > cd-icon {
+    margin-top: var(--cd-notification-item-icon-offset, -0.5rem);
+  }
+}
+
+.cd-notification-item__unread-dot {
+  flex-shrink: 0;
+  width: 0.5rem;
+  height: 0.5rem;
+  border-radius: 50%;
+  background-color: transparent;
+  margin-top: var(--cds-spacing-02);
+
+  &--visible {
+    background-color: var(--cds-interactive);
+  }
+}
+
+.cd-notification-item__title--unread {
+  font-weight: var(--cds-font-weight-semibold);
+}
+
+.cd-notification-item__content {
+  flex: 1;
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+  gap: var(--cds-spacing-02);
+}
+
+.cd-notification-item__timestamp {
+  color: var(--cds-text-secondary);
+  line-height: 1;
+  display: block;
+}
+
+.cd-notification-item__title {
+  margin: 0;
+  color: var(--cds-text-primary);
+  line-height: 1.25;
+  display: block;
+}
+
+.cd-notification-item__message {
+  color: var(--cds-text-helper);
+  margin: 0;
+  line-height: 1.4;
+  margin-top: calc(-1 * var(--cds-spacing-01));
+  display: block;
+}
+
+.cd-notification-item__preview {
+  color: var(--cds-text-secondary);
+  margin: 0;
+  display: -webkit-box;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+  overflow: hidden;
+}
+
+.cd-notification-item__right {
+  display: flex;
+  align-items: center;
+  gap: var(--cds-spacing-02);
+  flex-shrink: 0;
+}
+
+.cd-notification-item__app {
+  display: inline-flex;
+  align-items: center;
+  gap: var(--cds-spacing-02);
+  color: var(--cds-text-secondary);
+  margin-inline-start: auto;
+}
+
+.cd-notification-item__app-icon {
+  width: 1rem;
+  height: 1rem;
+}
+
+.cd-notification-item__chevron svg {
+  fill: var(--cds-icon-secondary);
+}
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
new file mode 100644 (file)
index 0000000..12d1766
--- /dev/null
@@ -0,0 +1,221 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+
+import { NotificationItemComponent } from './notification-item.component';
+import {
+  NotificationApplication,
+  NotificationType
+} from '~/app/shared/enum/notification-type.enum';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { CdNotification, CdNotificationConfig } from '~/app/shared/models/cd-notification';
+
+@Component({
+  template: `
+    <cd-notification-item
+      [type]="type"
+      [title]="title"
+      [timestamp]="timestamp"
+      [message]="message"
+      [notificationId]="notificationId"
+      [application]="application"
+      [showChevron]="showChevron"
+      [showUnread]="showUnread"
+      (deleted)="onDeleted($event)"
+    >
+    </cd-notification-item>
+  `,
+  standalone: false
+})
+class TestHostComponent {
+  type = NotificationType.error;
+  title = 'Test Title';
+  timestamp: Date | string | number = new Date();
+  message = 'Test message';
+  notificationId = 'test-1';
+  application: string;
+  showChevron = false;
+  showUnread = false;
+  deletedId: string = null;
+
+  onDeleted(id: string) {
+    this.deletedId = id;
+  }
+}
+
+describe('NotificationItemComponent', () => {
+  let fixture: ComponentFixture<TestHostComponent>;
+  let hostComponent: TestHostComponent;
+  let notificationService: NotificationService;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      imports: [SharedModule, HttpClientTestingModule],
+      declarations: [NotificationItemComponent, TestHostComponent],
+      schemas: [CUSTOM_ELEMENTS_SCHEMA]
+    }).compileComponents();
+  });
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(TestHostComponent);
+    hostComponent = fixture.componentInstance;
+    notificationService = TestBed.inject(NotificationService);
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    const el = fixture.nativeElement.querySelector('cd-notification-item');
+    expect(el).toBeTruthy();
+  });
+
+  it('should resolve the correct icon for the given type', () => {
+    const component = fixture.debugElement.children[0]
+      .componentInstance as NotificationItemComponent;
+    expect(component.iconMap[component.type]).toBe('error');
+  });
+
+  it('should render message as HTML when showChevron is false', () => {
+    const msgEl = fixture.nativeElement.querySelector('.cd-notification-item__message');
+    expect(msgEl).toBeTruthy();
+    expect(msgEl.textContent).toContain('Test message');
+  });
+
+  it('should render message as preview when showChevron is true', () => {
+    hostComponent.showChevron = true;
+    fixture.detectChanges();
+    const previewEl = fixture.nativeElement.querySelector('.cd-notification-item__preview');
+    expect(previewEl).toBeTruthy();
+    expect(previewEl.textContent.trim()).toBe('Test message');
+  });
+
+  it('should map all notification types to icons', () => {
+    const component = fixture.debugElement.children[0]
+      .componentInstance as NotificationItemComponent;
+    expect(component.iconMap[NotificationType.success]).toBe('success');
+    expect(component.iconMap[NotificationType.error]).toBe('error');
+    expect(component.iconMap[NotificationType.info]).toBe('infoCircle');
+    expect(component.iconMap[NotificationType.warning]).toBe('warning');
+    expect(component.iconMap['default']).toBe('infoCircle');
+  });
+
+  it('should render title', () => {
+    const titleEl = fixture.nativeElement.querySelector('.cd-notification-item__title');
+    expect(titleEl).toBeTruthy();
+    expect(titleEl.textContent.trim()).toBe('Test Title');
+  });
+
+  it('should always render timestamp at top', () => {
+    const contentEl = fixture.nativeElement.querySelector(
+      '.cd-notification-item__content .cd-notification-item__timestamp'
+    );
+    expect(contentEl).toBeTruthy();
+  });
+
+  it('should not render timestamp when not provided', () => {
+    hostComponent.timestamp = undefined;
+    fixture.detectChanges();
+    const timestampEls = fixture.nativeElement.querySelectorAll('.cd-notification-item__timestamp');
+    expect(timestampEls.length).toBe(0);
+  });
+
+  it('should not render title when empty', () => {
+    hostComponent.title = '';
+    fixture.detectChanges();
+    const titleEl = fixture.nativeElement.querySelector('.cd-notification-item__title');
+    expect(titleEl).toBeNull();
+  });
+
+  it('should show delete button by default when showChevron is false', () => {
+    const deleteBtn = fixture.nativeElement.querySelector('.cd-notification-item__delete');
+    expect(deleteBtn).toBeTruthy();
+  });
+
+  it('should show chevron and hide delete when showChevron is true', () => {
+    hostComponent.showChevron = true;
+    fixture.detectChanges();
+    const chevron = fixture.nativeElement.querySelector('.cd-notification-item__chevron');
+    const deleteBtn = fixture.nativeElement.querySelector('.cd-notification-item__delete');
+    expect(chevron).toBeTruthy();
+    expect(deleteBtn).toBeNull();
+  });
+
+  it('should render app badge with Ceph logo', () => {
+    hostComponent.application = NotificationApplication.Ceph;
+    fixture.detectChanges();
+    const appEl = fixture.nativeElement.querySelector('.cd-notification-item__app');
+    expect(appEl).toBeTruthy();
+    const img = appEl.querySelector('.cd-notification-item__app-icon');
+    expect(img.getAttribute('src')).toBe('assets/Ceph_Logo.svg');
+    expect(img.getAttribute('alt')).toBe('Ceph');
+  });
+
+  it('should render Prometheus logo when application is Prometheus', () => {
+    hostComponent.application = NotificationApplication.Prometheus;
+    fixture.detectChanges();
+    const img = fixture.nativeElement.querySelector('.cd-notification-item__app-icon');
+    expect(img.getAttribute('src')).toBe('assets/prometheus_logo.svg');
+  });
+
+  it('should not render app badge when application is not set', () => {
+    const appEl = fixture.nativeElement.querySelector('.cd-notification-item__app');
+    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');
+
+    const deleteBtn = fixture.nativeElement.querySelector('.cd-notification-item__delete');
+    deleteBtn.click();
+
+    expect(notificationService.remove).toHaveBeenCalledWith(0);
+    expect(hostComponent.deletedId).toBe('test-1');
+  });
+
+  it('should show dot placeholder (no color) in list panel when read', () => {
+    hostComponent.showChevron = true;
+    hostComponent.showUnread = false;
+    fixture.detectChanges();
+    const dot = fixture.nativeElement.querySelector('.cd-notification-item__unread-dot');
+    expect(dot).toBeTruthy();
+    expect(dot.classList).not.toContain('cd-notification-item__unread-dot--visible');
+  });
+
+  it('should show colored unread dot in list panel when unread', () => {
+    hostComponent.showChevron = true;
+    hostComponent.showUnread = true;
+    fixture.detectChanges();
+    const dot = fixture.nativeElement.querySelector('.cd-notification-item__unread-dot');
+    expect(dot).toBeTruthy();
+    expect(dot.classList).toContain('cd-notification-item__unread-dot--visible');
+  });
+
+  it('should show severity icon and no dot when not in list panel', () => {
+    const dot = fixture.nativeElement.querySelector('.cd-notification-item__unread-dot');
+    const icon = fixture.nativeElement.querySelector('cd-icon');
+    expect(dot).toBeNull();
+    expect(icon).toBeTruthy();
+  });
+
+  it('should apply unread title class when showUnread is true', () => {
+    hostComponent.showUnread = true;
+    fixture.detectChanges();
+    const titleEl = fixture.nativeElement.querySelector('.cd-notification-item__title');
+    expect(titleEl.classList).toContain('cd-notification-item__title--unread');
+  });
+
+  it('should not emit deleted when notification is not found', () => {
+    spyOn(notificationService, 'getNotificationsSnapshot').and.returnValue([]);
+    spyOn(notificationService, 'remove');
+
+    const deleteBtn = fixture.nativeElement.querySelector('.cd-notification-item__delete');
+    deleteBtn.click();
+
+    expect(notificationService.remove).not.toHaveBeenCalled();
+    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
new file mode 100644 (file)
index 0000000..7e0d019
--- /dev/null
@@ -0,0 +1,56 @@
+import { Component, EventEmitter, Input, Output, ViewEncapsulation } from '@angular/core';
+import {
+  NotificationApplication,
+  NotificationType
+} from '~/app/shared/enum/notification-type.enum';
+import { NotificationService } from '~/app/shared/services/notification.service';
+
+@Component({
+  selector: 'cd-notification-item',
+  templateUrl: './notification-item.component.html',
+  styleUrls: ['./notification-item.component.scss'],
+  encapsulation: ViewEncapsulation.None,
+  standalone: false
+})
+export class NotificationItemComponent {
+  /* Notification severity */
+  @Input() type: NotificationType = NotificationType.info;
+  /* Notification heading text */
+  @Input() title: string = '';
+  /* Timestamp value */
+  @Input() timestamp: Date | string | number;
+  /* Notification body text or preview */
+  @Input() message: string = '';
+  /* Unique notification id â€” required for delete */
+  @Input() notificationId: string;
+  /* Source application â€” renders logo and absolute date */
+  @Input() application: string;
+  /* Show chevron instead of delete button */
+  @Input() showChevron: boolean = false;
+  /* Replace severity icon with unread dot */
+  @Input() showUnread: boolean = false;
+  /* Emitted after notification is deleted via the service */
+  @Output() deleted = new EventEmitter<string>();
+
+  readonly NotificationApplication = NotificationApplication;
+
+  constructor(private notificationService: NotificationService) {}
+
+  readonly iconMap = {
+    [NotificationType.success]: 'success',
+    [NotificationType.error]: 'error',
+    [NotificationType.info]: 'infoCircle',
+    [NotificationType.warning]: 'warning',
+    default: 'infoCircle'
+  } as const;
+
+  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);
+      this.deleted.emit(this.notificationId);
+    }
+  }
+}
index 4b614761a3c3a4bbfdf32d0999b64880cdd1308f..f36586c567af69d95fcba65b8b7fc60a8406e80f 100644 (file)
@@ -1,7 +1,4 @@
-<div
-  class="notification-panel"
-  cdsTheme="g10"
->
+<div class="notification-panel content-theme cds--layer-one">
   <cd-notification-header></cd-notification-header>
   <cd-notification-area></cd-notification-area>
   <cd-notification-footer></cd-notification-footer>
index d81e7dd6fd8e782ee03e32520f5239455027a038..1945ac7474fce3261ad271a3226a94b5fa3be331 100644 (file)
-<div
-  cdsGrid
-  [useCssGrid]="false"
-  [fullWidth]="true"
-  class="notifications-page__container"
->
-  <!-- Left Panel - Notifications List -->
-  <div cdsRow>
-    <div
-      cdsCol
-      [columnNumbers]="{ sm: 4, md: 4, lg: 6 }"
+<div class="notifications-page">
+  <header class="notifications-page__header border-subtle-bottom">
+    <a
+      cdsLink
+      class="notifications-page__header-action"
+      tabindex="0"
+      (click)="goBack()"
+      (keydown.enter)="goBack()"
     >
-      <cds-search
-        [size]="'md'"
-        placeholder="Search notifications..."
-        i18n-placeholder
-        [(ngModel)]="searchText"
-        (valueChange)="onSearch($event)"
-      >
-      </cds-search>
-      <cds-structured-list
-        class="notifications-list"
-        *ngIf="filteredNotifications.length > 0"
-      >
-        <cds-list-row
-          *ngFor="let notification of filteredNotifications; trackBy: trackByNotificationId"
-          [class.active]="selectedNotificationID === notification.id"
-          (click)="onNotificationSelect(notification)"
-          class="notification-row"
-        >
-          <cds-list-column>
-            <div class="notification-item-content">
-              <h6 class="notification-title">
-                <ng-container *ngIf="notification.prometheusAlert; else regularTitle">
-                  {{ notification.prometheusAlert.alertName }}
-                  <cds-tag
-                    [type]="
-                      notification.prometheusAlert.status === 'active' ||
-                      notification.prometheusAlert.status === 'firing'
-                        ? 'red'
-                        : notification.prometheusAlert.status === 'resolved'
-                          ? 'green'
-                          : 'blue'
-                    "
-                    [size]="'sm'"
-                    class="ml-2"
-                  >
-                    {{ notification.prometheusAlert.status }}
-                  </cds-tag>
-                </ng-container>
-                <ng-template #regularTitle>
-                  {{ notification.title }}
-                </ng-template>
-              </h6>
-              <small class="notification-meta">
-                {{ notification.application }}
-                <ng-container *ngIf="notification.prometheusAlert">
-                  â€¢ {{ notification.prometheusAlert.severity }}
-                  <ng-container *ngIf="notification.prometheusAlert.instance">
-                    â€¢ {{ notification.prometheusAlert.instance }}
-                  </ng-container>
-                </ng-container>
-              </small>
-            </div>
-          </cds-list-column>
-          <cds-list-column nowrap="true">
-            <small class="notification-date">{{ formatDate(notification.timestamp) }}</small>
-          </cds-list-column>
-        </cds-list-row>
-      </cds-structured-list>
-      <div
-        *ngIf="filteredNotifications.length === 0"
-        class="empty-state"
-        cdsRow
-      >
-        <svg
-          cdsIcon="notification"
-          size="20"
-          class="empty-icon"
-        ></svg>
-        <p *ngIf="searchText">No notifications match your search</p>
-        <p *ngIf="!searchText">No notifications available</p>
-      </div>
-    </div>
-    <!-- Right Panel - Notifications Details -->
-    <div
-      cdsCol
-      [columnNumbers]="{ sm: 12, md: 12, lg: 10 }"
+      <cd-icon
+        type="left"
+        [useDefault]="true"
+      ></cd-icon>
+      <span i18n>Back</span>
+    </a>
+    <h4
+      class="notifications-page__header-title cds--type-heading-03"
+      i18n
     >
-      <div
-        cdsGrid
-        [fullWidth]="true"
-        *ngIf="selectedNotification"
-        class="notification-details"
-      >
+      Notifications
+    </h4>
+    <a
+      cdsLink
+      class="notifications-page__header-action"
+      tabindex="0"
+      (click)="clearAll()"
+      (keydown.enter)="clearAll()"
+      i18n
+    >
+      Clear all
+    </a>
+  </header>
+
+  <div class="notifications-page__body">
+    <div class="notifications-page__list border-subtle-right">
+      @for (notification of notifications(); track notification.id) {
         <div
-          cdsCol
-          [columnNumbers]="{ sm: 16, md: 16, lg: 16 }"
+          class="notifications-page__item border-subtle-bottom"
+          [class.notifications-page__item--active]="selectedNotificationID() === notification.id"
+          [class.notifications-page__item--unread]="!readMap()[notification.id]"
+          role="button"
+          tabindex="0"
+          (click)="onNotificationSelect(notification)"
+          (keydown.enter)="onNotificationSelect(notification)"
         >
-          <h3 cdsRow>{{ selectedNotification.title }}</h3>
-          <cds-structured-list
-            cdsRow
-            class="details-list mt-4"
+          <cd-notification-item
+            [type]="notification.type"
+            [title]="notification.displayTitle"
+            [timestamp]="notification.timestamp"
+            [message]="notification.displayPreview"
+            [notificationId]="notification.id"
+            [showChevron]="true"
+            [showUnread]="!readMap()[notification.id]"
+          >
+          </cd-notification-item>
+        </div>
+      } @empty {
+        <div class="notifications-page__empty">
+          <cd-icon
+            type="notification"
+            [size]="iconSize.size20"
+            [useDefault]="true"
+          ></cd-icon>
+          <p
+            class="cds-mt-3"
+            i18n
           >
-            <cds-list-row>
-              <cds-list-column
-                nowrap="true"
-                class="detail-label"
-                >Application:</cds-list-column
-              >
-              <cds-list-column>{{ selectedNotification.application }}</cds-list-column>
-            </cds-list-row>
-            <cds-list-row>
-              <cds-list-column
-                nowrap="true"
-                class="detail-label"
-                >Type:</cds-list-column
-              >
-              <cds-list-column>
-                <cds-tag
-                  [type]="
-                    selectedNotification.type === 0
-                      ? 'red'
-                      : selectedNotification.type === 1
-                        ? 'blue'
-                        : selectedNotification.type === 2
-                          ? 'green'
-                          : 'yellow'
-                  "
-                  [size]="'sm'"
-                >
-                  {{
-                    selectedNotification.type === 0
-                      ? 'Error'
-                      : selectedNotification.type === 1
-                        ? 'Info'
-                        : selectedNotification.type === 2
-                          ? 'Success'
-                          : 'Warning'
-                  }}
-                </cds-tag>
-              </cds-list-column>
-            </cds-list-row>
-            <cds-list-row>
-              <cds-list-column
-                nowrap="true"
-                class="detail-label"
-                >Date:</cds-list-column
-              >
-              <cds-list-column>{{ formatDate(selectedNotification.timestamp) }}</cds-list-column>
-            </cds-list-row>
-            <cds-list-row>
-              <cds-list-column
-                nowrap="true"
-                class="detail-label"
-                >Time:</cds-list-column
-              >
-              <cds-list-column>{{ formatTime(selectedNotification.timestamp) }}</cds-list-column>
-            </cds-list-row>
-            <!-- Prometheus-specific metadata fields -->
-            <ng-container *ngIf="selectedNotification.prometheusAlert">
-              <cds-list-row>
-                <cds-list-column
-                  nowrap="true"
-                  class="detail-label"
-                  >Alert Name:</cds-list-column
-                >
-                <cds-list-column>{{
-                  selectedNotification.prometheusAlert.alertName
-                }}</cds-list-column>
-              </cds-list-row>
-              <cds-list-row>
-                <cds-list-column
-                  nowrap="true"
-                  class="detail-label"
-                  >Status:</cds-list-column
-                >
-                <cds-list-column>
-                  <cds-tag
-                    [type]="
-                      selectedNotification.prometheusAlert.status === 'active' ||
-                      selectedNotification.prometheusAlert.status === 'firing'
-                        ? 'red'
-                        : selectedNotification.prometheusAlert.status === 'resolved'
-                          ? 'green'
-                          : selectedNotification.prometheusAlert.status === 'suppressed'
-                            ? 'gray'
-                            : 'blue'
-                    "
-                    [size]="'sm'"
-                  >
-                    {{ selectedNotification.prometheusAlert.status }}
-                  </cds-tag>
-                </cds-list-column>
-              </cds-list-row>
-              <cds-list-row>
-                <cds-list-column
-                  nowrap="true"
-                  class="detail-label"
-                  >Severity:</cds-list-column
-                >
-                <cds-list-column>
-                  <cds-tag
-                    [type]="
-                      selectedNotification.prometheusAlert.severity === 'critical'
-                        ? 'red'
-                        : selectedNotification.prometheusAlert.severity === 'warning'
-                          ? 'yellow'
-                          : selectedNotification.prometheusAlert.severity === 'resolved'
-                            ? 'green'
-                            : 'blue'
-                    "
-                    [size]="'sm'"
-                  >
-                    {{ selectedNotification.prometheusAlert.severity }}
-                  </cds-tag>
-                </cds-list-column>
-              </cds-list-row>
-              <cds-list-row *ngIf="selectedNotification.prometheusAlert.instance">
-                <cds-list-column
-                  nowrap="true"
-                  class="detail-label"
-                  >Instance:</cds-list-column
-                >
-                <cds-list-column>{{
-                  selectedNotification.prometheusAlert.instance
-                }}</cds-list-column>
-              </cds-list-row>
-              <cds-list-row *ngIf="selectedNotification.prometheusAlert.job">
-                <cds-list-column
-                  nowrap="true"
-                  class="detail-label"
-                  >Job:</cds-list-column
-                >
-                <cds-list-column>{{ selectedNotification.prometheusAlert.job }}</cds-list-column>
-              </cds-list-row>
-              <cds-list-row>
-                <cds-list-column
-                  nowrap="true"
-                  class="detail-label"
-                  >Description:</cds-list-column
-                >
-                <cds-list-column>
-                  <p class="message-content">
-                    {{ selectedNotification.prometheusAlert.description }}
-                  </p>
-                </cds-list-column>
-              </cds-list-row>
-              <cds-list-row *ngIf="selectedNotification.prometheusAlert.sourceUrl">
-                <cds-list-column
-                  nowrap="true"
-                  class="detail-label"
-                  >Source:</cds-list-column
-                >
-                <cds-list-column>
-                  <a
-                    [href]="selectedNotification.prometheusAlert.sourceUrl"
-                    target="_blank"
-                    class="source-link"
-                  >
-                    <svg
-                      cdsIcon="launch"
-                      size="16"
-                    ></svg>
-                    View in Prometheus
-                  </a>
-                </cds-list-column>
-              </cds-list-row>
-              <cds-list-row *ngIf="selectedNotification.prometheusAlert.fingerprint">
-                <cds-list-column
-                  nowrap="true"
-                  class="detail-label"
-                  >Fingerprint:</cds-list-column
-                >
-                <cds-list-column>
-                  <code class="fingerprint">{{
-                    selectedNotification.prometheusAlert.fingerprint
-                  }}</code>
-                </cds-list-column>
-              </cds-list-row>
-            </ng-container>
-            <!-- Regular message for non-Prometheus notifications -->
-            <cds-list-row
-              cdsRow
-              *ngIf="selectedNotification.message && !selectedNotification.prometheusAlert"
+            No notifications available
+          </p>
+        </div>
+      }
+    </div>
+
+    <div class="notifications-page__detail">
+      @if (selectedNotification(); as selected) {
+        <div class="notifications-page__detail-header">
+          <cd-notification-item
+            [type]="selected.type"
+            [title]="selected.displayTitle"
+            [timestamp]="selected.timestamp"
+            [notificationId]="selected.id"
+            [application]="selected.application"
+            (deleted)="onNotificationDeleted($event)"
+          ></cd-notification-item>
+        </div>
+        <div class="notifications-page__detail-body cds-mt-5">
+          <p class="notifications-page__detail-text cds--type-body-01">
+            {{ selected.displayPreview }}
+          </p>
+          @if (selected.prometheusAlert?.sourceUrl) {
+            <a
+              cdsLink
+              class="cds-mt-5"
+              [href]="selected.prometheusAlert.sourceUrl"
+              target="_blank"
+              rel="noopener noreferrer"
+              i18n
             >
-              <cds-list-column
-                nowrap="true"
-                class="detail-label"
-                >Message:</cds-list-column
-              >
-              <cds-list-column>
-                <p class="message-content">{{ selectedNotification.message }}</p>
-              </cds-list-column>
-            </cds-list-row>
-          </cds-structured-list>
+              View in Prometheus
+            </a>
+          }
+        </div>
+      } @else {
+        <div class="notifications-page__empty">
+          <cd-icon
+            type="notification"
+            [size]="iconSize.size20"
+            [useDefault]="true"
+          ></cd-icon>
+          <p
+            class="cds-mt-3"
+            i18n
+          >
+            Select a notification to view details
+          </p>
         </div>
-      </div>
+      }
     </div>
   </div>
 </div>
index b37aacd732573478618ce282ba5cf1ec91e17eed..4d5d87697f74230b58e3f572fdd79b0ad15fa6d0 100644 (file)
-// Main container
-.notifications-page__container {
-  padding-top: var(--cds-spacing-05);
-  background-color: var(--cds-layer-01);
-  column-gap: 1rem;
-}
-
-// Notifications list
-.notifications-list {
-  cds-list-row {
-    cursor: pointer;
-    transition: background-color 0.2s ease;
+.notifications-page {
+  --cd-notification-item-icon-offset: calc(-1 * var(--cds-spacing-02));
+
+  display: grid;
+  grid-template-rows: auto 1fr;
+  height: calc(100vh - 3rem);
+  background-color: var(--cds-background);
+
+  &__header {
+    display: grid;
+    grid-template-columns: 1fr auto 1fr;
+    align-items: center;
+    padding: var(--cds-spacing-04) var(--cds-spacing-06);
+    background-color: var(--cds-layer-01);
+  }
 
-    &:hover {
-      background-color: var(--cds-layer-hover);
-    }
+  &__header-action {
+    display: inline-flex;
+    align-items: center;
+    gap: var(--cds-spacing-02);
 
-    &.active {
-      background-color: var(--cds-layer-selected);
-
-      .notification-title {
-        color: var(--cds-text-primary);
-        font-weight: var(--cds-font-weight-semibold);
-      }
+    &:last-child {
+      justify-self: end;
     }
   }
-}
 
-// Notification item content
-.notification-item-content {
-  .notification-title {
-    margin: 0 0 var(--cds-spacing-02) 0;
-    font-size: var(--cds-productive-heading-compact-01-font-size);
-    line-height: var(--cds-productive-heading-compact-01-line-height);
-    font-weight: var(--cds-productive-heading-compact-01-font-weight);
+  &__header-title {
+    text-align: center;
     color: var(--cds-text-primary);
-  }
-
-  .notification-meta {
-    color: var(--cds-text-secondary);
-    font-size: var(--cds-label-01-font-size);
-    line-height: var(--cds-label-01-line-height);
-  }
-}
-
-.notification-date {
-  color: var(--cds-text-secondary);
-  font-size: var(--cds-label-01-font-size);
-  white-space: nowrap;
-}
-
-// Empty state
-.empty-state {
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  color: var(--cds-text-secondary);
-  margin-top: var(--cds-spacing-05);
-
-  p {
     margin: 0;
-    font-size: var(--cds-body-01-font-size);
-    line-height: var(--cds-body-01-line-height);
   }
-}
 
-// No selection state
-.no-selection-state {
-  text-align: center;
-  color: var(--cds-text-secondary);
-  margin-top: var(--cds-spacing-06);
+  &__body {
+    display: grid;
+    grid-template-columns: minmax(20rem, 1fr) 2fr;
+    overflow: hidden;
+  }
 
-  p {
-    margin: var(--cds-spacing-03) 0 0 0;
-    font-size: var(--cds-body-01-font-size);
-    line-height: var(--cds-body-01-line-height);
+  &__list {
+    overflow-y: auto;
+    background-color: var(--cds-layer-01);
   }
-}
 
-// Notification details
-.notification-details {
-  padding: var(--cds-spacing-05);
-  border-radius: var(--cds-border-radius);
-  background-color: var(--cds-layer-01);
-}
+  &__item {
+    padding: var(--cds-spacing-03) var(--cds-spacing-05);
+    cursor: pointer;
+    transition: background-color 0.15s ease;
 
-// Details list
-.details-list {
-  cds-list-row {
-    border-bottom: 1px solid var(--cds-border-subtle);
+    &:hover {
+      background-color: var(--cds-layer-hover);
+    }
 
-    &:last-child {
-      border-bottom: none;
+    &--active {
+      background-color: var(--cds-layer-selected);
     }
 
-    &:hover {
-      background-color: transparent;
+    &--unread &-title {
+      font-weight: var(--cds-font-weight-semibold);
     }
   }
 
-  .detail-label {
-    color: var(--cds-text-secondary);
-    font-weight: var(--cds-font-weight-semibold);
-    font-size: var(--cds-body-compact-01-font-size);
+  &__item .cd-notification-item__title {
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
   }
 
-  cds-list-column:last-child {
-    color: var(--cds-text-primary);
-    font-size: var(--cds-body-compact-01-font-size);
-    line-height: var(--cds-body-compact-01-line-height);
+  &__detail {
+    overflow-y: auto;
+    padding: var(--cds-spacing-06) var(--cds-spacing-07);
+    background-color: var(--cds-layer-01);
   }
-}
-
-// Message content
-.message-content {
-  margin: 0;
-  padding: var(--cds-spacing-04);
-  background-color: var(--cds-layer-02);
-  border-radius: var(--cds-border-radius);
-  border-left: 3px solid var(--cds-support-info);
-  font-size: var(--cds-body-compact-01-font-size);
-  line-height: var(--cds-body-compact-01-line-height);
-  color: var(--cds-text-primary);
-}
-
-// Timestamp
-.timestamp {
-  color: var(--cds-text-secondary);
-  font-family: var(--cds-font-mono);
-  font-size: var(--cds-code-01-font-size);
-}
-
-// Icons
-.empty-icon,
-.no-selection-icon {
-  fill: var(--cds-icon-secondary);
-}
-
-.no-selection-icon {
-  margin-bottom: var(--cds-spacing-05);
-}
-
-// Text utilities
-.text-muted {
-  color: var(--cds-text-secondary);
-}
 
-// Typography
-
-h3 {
-  font-size: var(--cds-productive-heading-03-font-size);
-  line-height: var(--cds-productive-heading-03-line-height);
-  font-weight: var(--cds-productive-heading-03-font-weight);
-  color: var(--cds-text-primary);
-  margin-bottom: var(--cds-spacing-05);
-}
-
-p {
-  font-size: var(--cds-body-01-font-size);
-  line-height: var(--cds-body-01-line-height);
-  color: var(--cds-text-primary);
-}
+  &__detail-header cd-notification-item {
+    .cd-notification-item__title {
+      font-size: var(--cds-heading-compact-02-font-size);
+      font-weight: var(--cds-heading-compact-02-font-weight);
+      line-height: var(--cds-heading-compact-02-line-height);
+    }
+  }
 
-// Spacing utilities
-.mb-4 {
-  margin-bottom: var(--cds-spacing-05);
-}
+  &__detail-text {
+    margin: 0;
+    color: var(--cds-text-primary);
+  }
 
-.mt-4 {
-  margin-top: var(--cds-spacing-05);
-}
+  &__empty {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    padding: var(--cds-spacing-09) 0;
+    color: var(--cds-text-secondary);
+  }
 
-.mt-5 {
-  margin-top: var(--cds-spacing-06);
+  &__empty cd-icon svg {
+    fill: var(--cds-icon-secondary);
+  }
 }
index dc08230b51115cbd1acecc82ae9329824b59a421..c91d906ef221b2b98ded18a6d0b4e632c01757dd 100644 (file)
@@ -1,13 +1,7 @@
 import { ComponentFixture, TestBed } from '@angular/core/testing';
-import { FormsModule } from '@angular/forms';
 import { BehaviorSubject, Subscription } from 'rxjs';
-import {
-  IconModule,
-  SearchModule,
-  StructuredListModule,
-  TagModule,
-  GridModule
-} from 'carbon-components-angular';
+import { Location } from '@angular/common';
+import { IconModule, GridModule, LinkModule } from 'carbon-components-angular';
 
 import { NotificationsPageComponent } from './notifications-page.component';
 import { CdNotification } from '~/app/shared/models/cd-notification';
@@ -16,25 +10,38 @@ import { NotificationService } from '~/app/shared/services/notification.service'
 import { PrometheusAlertService } from '~/app/shared/services/prometheus-alert.service';
 import { PrometheusNotificationService } from '~/app/shared/services/prometheus-notification.service';
 import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
-import { ChangeDetectorRef } from '@angular/core';
+import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
+import { SharedModule } from '~/app/shared/shared.module';
 
 describe('NotificationsPageComponent', () => {
   let component: NotificationsPageComponent;
   let fixture: ComponentFixture<NotificationsPageComponent>;
   let mockNotifications: CdNotification[];
   let dataSourceSubject: BehaviorSubject<CdNotification[]>;
+  let readMapSubject: BehaviorSubject<Record<string, boolean>>;
   let notificationService: any;
+  let mockLocation: any;
 
-  // Create mocks
   const createMockNotificationService = () => {
     dataSourceSubject = new BehaviorSubject<CdNotification[]>([]);
+    readMapSubject = new BehaviorSubject<Record<string, boolean>>({});
     return {
       data$: dataSourceSubject.asObservable(),
+      readMap$: readMapSubject.asObservable(),
       dataSource: {
         getValue: () => dataSourceSubject.getValue(),
         next: (value: CdNotification[]) => dataSourceSubject.next(value)
       },
-      remove: jasmine.createSpy('remove')
+      remove: jasmine.createSpy('remove'),
+      removeAll: jasmine.createSpy('removeAll'),
+      markAsRead: jasmine.createSpy('markAsRead').and.callFake((id: string) => {
+        const current = readMapSubject.getValue();
+        if (!current[id]) {
+          const updated = { ...current, [id]: true };
+          readMapSubject.next(updated);
+          localStorage.setItem('cdNotificationsRead', JSON.stringify(updated));
+        }
+      })
     };
   };
 
@@ -53,11 +60,6 @@ describe('NotificationsPageComponent', () => {
     })
   };
 
-  const mockChangeDetectorRef = {
-    detectChanges: jasmine.createSpy('detectChanges')
-  };
-
-  // Create mock notifications
   const createMockNotification = (overrides: any): CdNotification => {
     return {
       id: overrides.id,
@@ -66,21 +68,11 @@ describe('NotificationsPageComponent', () => {
       application: overrides.application || '',
       timestamp: overrides.timestamp || new Date().toISOString(),
       type: overrides.type || NotificationType.info,
-      priority: 'normal',
       textClass: '',
       iconClass: '',
       duration: 0,
       borderClass: '',
-      timeout: 0,
-      isError: false,
       isFinishedTask: false,
-      progress: 0,
-      progressText: '',
-      task: undefined,
-      error: undefined,
-      isSilent: false,
-      silentNotifications: [],
-      userData: undefined,
       alertSilenced: false,
       ...overrides
     } as CdNotification;
@@ -114,30 +106,30 @@ describe('NotificationsPageComponent', () => {
       })
     ];
 
+    mockLocation = { back: jasmine.createSpy('back') };
     const mockNotificationService = createMockNotificationService();
-    notificationService = mockNotificationService; // Store reference
+    notificationService = mockNotificationService;
+
+    localStorage.removeItem('cdNotificationsRead');
 
     await TestBed.configureTestingModule({
-      imports: [FormsModule, GridModule, IconModule, SearchModule, StructuredListModule, TagModule],
+      imports: [GridModule, IconModule, LinkModule, SharedModule],
       declarations: [NotificationsPageComponent],
       providers: [
         { provide: NotificationService, useValue: mockNotificationService },
         { provide: PrometheusAlertService, useValue: mockPrometheusAlertService },
         { provide: PrometheusNotificationService, useValue: mockPrometheusNotificationService },
         { provide: AuthStorageService, useValue: mockAuthStorageService },
-        { provide: ChangeDetectorRef, useValue: mockChangeDetectorRef }
-      ]
+        { provide: Location, useValue: mockLocation }
+      ],
+      schemas: [CUSTOM_ELEMENTS_SCHEMA]
     }).compileComponents();
   });
 
   beforeEach(() => {
     fixture = TestBed.createComponent(NotificationsPageComponent);
     component = fixture.componentInstance;
-
-    // Update the data source with mock notifications BEFORE ngOnInit
     dataSourceSubject.next(mockNotifications);
-
-    // Initialize the component
     fixture.detectChanges();
   });
 
@@ -145,6 +137,7 @@ describe('NotificationsPageComponent', () => {
     if (component['interval']) {
       window.clearInterval(component['interval']);
     }
+    localStorage.removeItem('cdNotificationsRead');
   });
 
   it('should create', () => {
@@ -152,145 +145,148 @@ describe('NotificationsPageComponent', () => {
   });
 
   it('should load notifications on init', () => {
-    expect(component.notifications).toEqual(mockNotifications);
-    expect(component.filteredNotifications).toEqual(mockNotifications);
+    expect(component.notifications().length).toBe(3);
   });
 
   it('should select notification when clicked', () => {
-    const notification = mockNotifications[0];
+    const notification = component.notifications()[0];
     component.onNotificationSelect(notification);
-    expect(component.selectedNotification).toBe(notification);
+    expect(component.selectedNotification()).toBe(notification);
   });
 
-  describe('search functionality', () => {
-    it('should filter notifications by title', () => {
-      component.onSearch('Success');
-      expect(component.filteredNotifications.length).toBe(1);
-      expect(component.filteredNotifications[0].title).toBe('Success Notification');
-    });
-
-    it('should filter notifications by message', () => {
-      component.onSearch('error');
-      expect(component.filteredNotifications.length).toBe(1);
-      expect(component.filteredNotifications[0].title).toBe('Error Notification');
-    });
-
-    it('should filter notifications by application', () => {
-      component.onSearch('Updates');
-      expect(component.filteredNotifications.length).toBe(1);
-      expect(component.filteredNotifications[0].application).toBe('Updates');
-    });
-
-    it('should show all notifications when search is cleared', () => {
-      component.onSearch('');
-      expect(component.filteredNotifications).toEqual(mockNotifications);
+  describe('back navigation', () => {
+    it('should call location.back()', () => {
+      component.goBack();
+      expect(mockLocation.back).toHaveBeenCalled();
     });
+  });
 
-    it('should be case insensitive', () => {
-      component.onSearch('SUCCESS');
-      expect(component.filteredNotifications.length).toBe(1);
-      expect(component.filteredNotifications[0].title).toBe('Success Notification');
+  describe('clear all', () => {
+    it('should call removeAll and clear selection', () => {
+      component.selectedNotificationID.set('1');
+      component.clearAll();
+      expect(notificationService.removeAll).toHaveBeenCalled();
+      expect(component.selectedNotificationID()).toBeNull();
     });
   });
 
-  describe('notification removal', () => {
-    it('should remove notification', () => {
-      const notification = mockNotifications[0];
+  describe('remove notification', () => {
+    it('should remove a notification and stop event propagation', () => {
       const mockEvent = {
-        stopPropagation: jasmine.createSpy('stopPropagation'),
-        preventDefault: jasmine.createSpy('preventDefault')
-      };
-
-      component.removeNotification(notification, mockEvent as any);
-
+        stopPropagation: jasmine.createSpy('stopPropagation')
+      } as any;
+      component.removeNotification(component.notifications()[0], mockEvent);
       expect(mockEvent.stopPropagation).toHaveBeenCalled();
-      expect(mockEvent.preventDefault).toHaveBeenCalled();
       expect(notificationService.remove).toHaveBeenCalledWith(0);
     });
 
     it('should clear selection if removed notification was selected', () => {
-      const notification = mockNotifications[0];
-      component.selectedNotificationID = notification.id;
-
-      const mockEvent = {
-        stopPropagation: jasmine.createSpy('stopPropagation'),
-        preventDefault: jasmine.createSpy('preventDefault')
-      };
-
-      component.removeNotification(notification, mockEvent as any);
-
-      const selectedNotification = component.selectedNotification;
-
-      expect(selectedNotification).toBeUndefined();
-    });
-  });
-
-  describe('icon handling', () => {
-    it('should return correct Carbon icon for success', () => {
-      expect(component.getCarbonIcon(NotificationType.success)).toBe('checkmark--filled');
-    });
-
-    it('should return correct Carbon icon for error', () => {
-      expect(component.getCarbonIcon(NotificationType.error)).toBe('error--filled');
+      component.selectedNotificationID.set('1');
+      const mockEvent = { stopPropagation: jasmine.createSpy() } as any;
+      component.removeNotification(component.notifications()[0], mockEvent);
+      expect(component.selectedNotificationID()).toBeNull();
     });
 
-    it('should return correct Carbon icon for info', () => {
-      expect(component.getCarbonIcon(NotificationType.info)).toBe('information--filled');
+    it('should not clear selection if a different notification was removed', () => {
+      component.selectedNotificationID.set('1');
+      const mockEvent = { stopPropagation: jasmine.createSpy() } as any;
+      component.removeNotification(component.notifications()[1], mockEvent);
+      expect(component.selectedNotificationID()).toBe('1');
     });
+  });
 
-    it('should return correct Carbon icon for warning', () => {
-      expect(component.getCarbonIcon(NotificationType.warning)).toBe('warning--filled');
+  describe('onNotificationDeleted', () => {
+    it('should clear selection if deleted notification was selected', () => {
+      component.selectedNotificationID.set('1');
+      component.onNotificationDeleted('1');
+      expect(component.selectedNotificationID()).toBeNull();
     });
 
-    it('should return default icon for unknown type', () => {
-      expect(component.getCarbonIcon('')).toBe('notification--filled');
+    it('should not clear selection if a different notification was deleted', () => {
+      component.selectedNotificationID.set('1');
+      component.onNotificationDeleted('2');
+      expect(component.selectedNotificationID()).toBe('1');
     });
   });
 
-  describe('icon color classes', () => {
-    it('should return correct class for success', () => {
-      expect(component.getIconColorClass(NotificationType.success)).toBe('icon-success');
+  describe('read/unread tracking', () => {
+    it('should mark all notifications as unread initially', () => {
+      expect(component.readMap()[mockNotifications[0].id]).toBeFalsy();
+      expect(component.readMap()[mockNotifications[1].id]).toBeFalsy();
     });
 
-    it('should return correct class for error', () => {
-      expect(component.getIconColorClass(NotificationType.error)).toBe('icon-error');
+    it('should call markAsRead on the service when notification selected', () => {
+      component.onNotificationSelect(component.notifications()[0]);
+      expect(notificationService.markAsRead).toHaveBeenCalledWith('1');
     });
 
-    it('should return correct class for info', () => {
-      expect(component.getIconColorClass(NotificationType.info)).toBe('icon-info');
+    it('should reflect read state from service readMap$', () => {
+      component.onNotificationSelect(component.notifications()[0]);
+      fixture.detectChanges();
+      expect(component.readMap()['1']).toBe(true);
     });
 
-    it('should return correct class for warning', () => {
-      expect(component.getIconColorClass(NotificationType.warning)).toBe('icon-warning');
+    it('should persist read state to localStorage via service', () => {
+      component.onNotificationSelect(component.notifications()[0]);
+      const stored = JSON.parse(localStorage.getItem('cdNotificationsRead'));
+      expect(stored['1']).toBe(true);
     });
 
-    it('should return empty string for unknown type', () => {
-      expect(component.getIconColorClass('')).toBe('');
+    it('should reflect pre-existing read state from service readMap$', () => {
+      readMapSubject.next({ '2': true });
+      fixture.detectChanges();
+      expect(component.readMap()['2']).toBe(true);
+      expect(component.readMap()['1']).toBeFalsy();
     });
   });
 
-  describe('date formatting', () => {
-    it('should format today\'s date as "Today"', () => {
-      const today = new Date().toISOString();
-      expect(component.formatDate(today)).toBe('Today');
+  describe('displayTitle and displayPreview', () => {
+    it('should compute displayTitle from title for regular notifications', () => {
+      expect(component.notifications()[0].displayTitle).toBe('Success Notification');
     });
 
-    it('should format yesterday\'s date as "Yesterday"', () => {
-      const yesterday = new Date(Date.now() - 86400000).toISOString();
-      expect(component.formatDate(yesterday)).toBe('Yesterday');
+    it('should compute displayTitle from alertName for Prometheus notifications', () => {
+      const promNotification = createMockNotification({
+        id: '4',
+        title: 'Alert',
+        prometheusAlert: {
+          alertName: 'HighCPU',
+          status: 'firing',
+          severity: 'critical',
+          description: 'CPU high'
+        }
+      });
+      dataSourceSubject.next([promNotification]);
+      fixture.detectChanges();
+      expect(component.notifications()[0].displayTitle).toBe('HighCPU');
     });
 
-    it('should format older dates in short format', () => {
-      const oldDate = new Date('2023-01-15').toISOString();
-      expect(component.formatDate(oldDate)).toMatch(/[A-Z][a-z]{2} \d{1,2}/);
+    it('should compute displayPreview from message for regular notifications', () => {
+      expect(component.notifications()[0].displayPreview).toBe('Operation completed successfully');
+    });
+
+    it('should compute displayPreview from description for Prometheus notifications', () => {
+      const promNotification = createMockNotification({
+        id: '4',
+        title: 'Alert',
+        message: 'fallback',
+        prometheusAlert: {
+          alertName: 'HighCPU',
+          status: 'firing',
+          severity: 'critical',
+          description: 'CPU is above 90%'
+        }
+      });
+      dataSourceSubject.next([promNotification]);
+      fixture.detectChanges();
+      expect(component.notifications()[0].displayPreview).toBe('CPU is above 90%');
     });
-  });
 
-  describe('time formatting', () => {
-    it('should format time in 12-hour format', () => {
-      const date = new Date('2023-01-15T15:30:00').toISOString();
-      const formattedTime = component.formatTime(date);
-      expect(formattedTime).toMatch(/\d{1,2}:\d{2} [AP]M/);
+    it('should return empty string when no message', () => {
+      const emptyNotification = createMockNotification({ id: '5', message: '' });
+      dataSourceSubject.next([emptyNotification]);
+      fixture.detectChanges();
+      expect(component.notifications()[0].displayPreview).toBe('');
     });
   });
 
@@ -300,7 +296,6 @@ describe('NotificationsPageComponent', () => {
       configOpt: { read: true }
     });
 
-    // Re-initialize component to trigger ngOnInit with new permissions
     fixture = TestBed.createComponent(NotificationsPageComponent);
     component = fixture.componentInstance;
     dataSourceSubject.next(mockNotifications);
index c19181bf26f3ced5076a4989d18528531bfa8fc6..a250e2f15c185f5d3a6099889060b597cddc9594 100644 (file)
@@ -1,11 +1,25 @@
-import { Component, OnInit, OnDestroy, ChangeDetectionStrategy } from '@angular/core';
+import {
+  Component,
+  OnInit,
+  OnDestroy,
+  ChangeDetectionStrategy,
+  signal,
+  computed
+} from '@angular/core';
+import { toSignal } from '@angular/core/rxjs-interop';
+import { Location } from '@angular/common';
 import { Subscription } from 'rxjs';
 import { NotificationService } from '~/app/shared/services/notification.service';
 import { CdNotification } from '~/app/shared/models/cd-notification';
-import { NotificationType } from '~/app/shared/enum/notification-type.enum';
 import { PrometheusAlertService } from '~/app/shared/services/prometheus-alert.service';
 import { PrometheusNotificationService } from '~/app/shared/services/prometheus-notification.service';
 import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { IconSize } from '~/app/shared/enum/icons.enum';
+
+interface DisplayNotification extends CdNotification {
+  displayTitle: string;
+  displayPreview: string;
+}
 
 @Component({
   selector: 'cd-notifications-page',
@@ -15,10 +29,15 @@ import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
   standalone: false
 })
 export class NotificationsPageComponent implements OnInit, OnDestroy {
-  notifications: CdNotification[] = [];
-  selectedNotificationID: string | null = null;
-  searchText: string = '';
-  filteredNotifications: CdNotification[] = [];
+  iconSize = IconSize;
+  notifications = signal<DisplayNotification[]>([]);
+  selectedNotificationID = signal<string | null>(null);
+  readMap: ReturnType<typeof toSignal<Record<string, boolean>>>;
+
+  selectedNotification = computed(() =>
+    this.notifications().find((n) => n.id === this.selectedNotificationID())
+  );
+
   private sub: Subscription;
   private interval: number;
 
@@ -26,148 +45,75 @@ export class NotificationsPageComponent implements OnInit, OnDestroy {
     private notificationService: NotificationService,
     private prometheusAlertService: PrometheusAlertService,
     private prometheusNotificationService: PrometheusNotificationService,
-    private authStorageService: AuthStorageService
-  ) {}
+    private authStorageService: AuthStorageService,
+    private location: Location
+  ) {
+    this.readMap = toSignal(this.notificationService.readMap$, {
+      initialValue: {} as Record<string, boolean>
+    });
+  }
 
   ngOnInit(): void {
-    // Check permissions and trigger Prometheus alerts refresh
     const permissions = this.authStorageService.getPermissions();
     if (permissions.prometheus.read && permissions.configOpt.read) {
       this.triggerPrometheusAlerts();
-      // Set up periodic refresh similar to sidebar component
       this.interval = window.setInterval(() => {
         this.triggerPrometheusAlerts();
       }, 5000);
     }
 
-    // Subscribe to notifications from the service
     this.sub = this.notificationService.data$.subscribe((notifications) => {
-      this.notifications = notifications;
-
-      // preserve filtered array reference if search active
-      if (!this.searchText) {
-        this.filteredNotifications = notifications;
-      } else {
-        this.onSearch(this.searchText);
-      }
+      this.notifications.set(
+        notifications.map((n) =>
+          Object.assign(n, {
+            displayTitle: n.prometheusAlert?.alertName || n.title || '',
+            displayPreview: n.prometheusAlert?.description || n.message || ''
+          })
+        )
+      );
     });
   }
 
   ngOnDestroy(): void {
-    if (this.sub) {
-      this.sub.unsubscribe();
-    }
+    this.sub?.unsubscribe();
     if (this.interval) {
       window.clearInterval(this.interval);
     }
   }
 
-  onNotificationSelect(notification: CdNotification): void {
-    this.selectedNotificationID = notification.id;
+  goBack(): void {
+    this.location.back();
   }
 
-  get selectedNotification(): CdNotification {
-    return this.filteredNotifications.find((n) => n.id === this.selectedNotificationID);
+  clearAll(): void {
+    this.notificationService.removeAll();
+    this.selectedNotificationID.set(null);
   }
 
-  onSearch(value: string): void {
-    this.searchText = value;
-    if (!value || value.trim() === '') {
-      this.filteredNotifications = this.notifications;
-    } else {
-      const searchLower = value.toLowerCase();
-      this.filteredNotifications = this.notifications.filter(
-        (notification) =>
-          notification.title?.toLowerCase().includes(searchLower) ||
-          notification.message?.toLowerCase().includes(searchLower) ||
-          notification.application?.toLowerCase().includes(searchLower)
-      );
-    }
+  onNotificationSelect(notification: DisplayNotification): void {
+    this.selectedNotificationID.set(notification.id);
+    this.notificationService.markAsRead(notification.id);
   }
 
-  removeNotification(notification: CdNotification, event: MouseEvent): void {
-    // Stop event propagation to prevent panel closing
+  removeNotification(notification: DisplayNotification, event: MouseEvent): void {
     event.stopPropagation();
-    event.preventDefault();
-
-    // Get the notification index from the service's data
-    const notifications = this.notificationService['dataSource'].getValue();
-    const index = notifications.findIndex((n) => n.id === notification.id);
-
+    const index = this.notifications().findIndex((n) => n.id === notification.id);
     if (index > -1) {
-      // Remove the notification through the service
       this.notificationService.remove(index);
-
-      // Clear selection if the removed notification was selected
-      if (this.selectedNotificationID === notification.id) {
-        this.selectedNotificationID = null;
+      if (this.selectedNotificationID() === notification.id) {
+        this.selectedNotificationID.set(null);
       }
     }
   }
 
-  getCarbonIcon(type: NotificationType | string): string {
-    switch (type) {
-      case NotificationType.success:
-        return 'checkmark--filled';
-      case NotificationType.error:
-        return 'error--filled';
-      case NotificationType.info:
-        return 'information--filled';
-      case NotificationType.warning:
-        return 'warning--filled';
-      default:
-        return 'notification--filled';
-    }
-  }
-
-  getIconColorClass(type: NotificationType | string): string {
-    switch (type) {
-      case NotificationType.success:
-        return 'icon-success';
-      case NotificationType.error:
-        return 'icon-error';
-      case NotificationType.info:
-        return 'icon-info';
-      case NotificationType.warning:
-        return 'icon-warning';
-      default:
-        return '';
+  onNotificationDeleted(notificationId: string): void {
+    if (this.selectedNotificationID() === notificationId) {
+      this.selectedNotificationID.set(null);
     }
   }
 
-  formatDate(timestamp: string): string {
-    const date = new Date(timestamp);
-    const today = new Date();
-    const yesterday = new Date(today);
-    yesterday.setDate(yesterday.getDate() - 1);
-
-    if (date.toDateString() === today.toDateString()) {
-      return 'Today';
-    } else if (date.toDateString() === yesterday.toDateString()) {
-      return 'Yesterday';
-    } else {
-      return date.toLocaleDateString('en-US', {
-        month: 'short',
-        day: 'numeric'
-      });
-    }
-  }
-
-  formatTime(timestamp: string): string {
-    const date = new Date(timestamp);
-    return date.toLocaleTimeString('en-US', {
-      hour: 'numeric',
-      minute: '2-digit',
-      hour12: true
-    });
-  }
-
   private triggerPrometheusAlerts(): void {
     this.prometheusAlertService.refresh();
     this.prometheusNotificationService.refresh();
   }
-
-  trackByNotificationId(_index: number, notification: CdNotification): string {
-    return notification.id;
-  }
 }
index a702d0fe041130848d05f5bc686f1ceb3f19ac5f..3475990929469208d821c3c642fdc97f0d6233aa 100644 (file)
@@ -47,10 +47,6 @@ Using `color` in css and seyting svg will fill="currentColor does not work.
   fill: theme.$layer-selected-disabled !important;
 }
 
-.notificationNew-icon circle {
-  fill: theme.$support-error !important;
-}
-
 .emptySearch-icon {
   fill: theme.$layer-selected-disabled !important;
 }
index 22a047d462e1ccb1aff878b4c9f6c7a2ac61a3c9..fbccf2e940ee8a60b04215a8eb295f088d32a19f 100644 (file)
@@ -177,7 +177,8 @@ export const ICON_TYPE = {
   locked: 'locked',
   cloudMonitoring: 'cloud--monitoring',
   trash: 'trash-can',
-  temperature: 'temperature'
+  temperature: 'temperature',
+  left: 'arrow--left'
 } as const;
 
 export const EMPTY_STATE_IMAGE = {
index 7fd9e828392aec4a8d0e07ca85724222fde725f2..b631264d8eabb185a52f383d026449a003d73e08 100644 (file)
@@ -4,3 +4,8 @@ export enum NotificationType {
   success,
   warning
 }
+
+export enum NotificationApplication {
+  Ceph = 'Ceph',
+  Prometheus = 'Prometheus'
+}
index 017e6ae71913cad23a8f6d3370bbfbc4088aac9e..9da0326b4eeef578225d786080ecb00dc0560461 100644 (file)
@@ -28,18 +28,21 @@ export class NotificationService {
   private readonly QUEUE_DELAY = 500;
   private readonly LOCAL_STORAGE_KEY = 'cdNotifications';
   private readonly LOCAL_STORAGE_MUTE_KEY = 'cdNotificationsMuted';
+  private readonly LOCAL_STORAGE_READ_KEY = 'cdNotificationsRead';
 
   private dataSource = new BehaviorSubject<CdNotification[]>([]);
   private panelState = new BehaviorSubject<boolean>(false);
   private muteStateSource = new BehaviorSubject<boolean>(false);
   private activeToastsSource = new BehaviorSubject<ToastContent[]>([]);
   private hasUnreadSource = new BehaviorSubject<boolean>(false);
+  private readMapSource = new BehaviorSubject<Record<string, boolean>>({});
 
   data$ = this.dataSource.asObservable();
   panelState$ = this.panelState.asObservable();
   muteState$ = this.muteStateSource.asObservable();
   activeToasts$ = this.activeToastsSource.asObservable();
   hasUnread$ = this.hasUnreadSource.asObservable();
+  readMap$ = this.readMapSource.asObservable();
 
   private activeToasts: ToastContent[] = [];
   private queued: CdNotificationConfig[] = [];
@@ -51,6 +54,7 @@ export class NotificationService {
     private cdDatePipe: CdDatePipe,
     private ngZone: NgZone
   ) {
+    this._loadReadMap();
     this._loadStoredNotifications();
     this._loadMutedState();
   }
@@ -72,7 +76,24 @@ export class NotificationService {
       }
     }
     this.dataSource.next(notifications);
-    this.hasUnreadSource.next(notifications?.length > 0);
+    this._recomputeHasUnread(notifications);
+  }
+
+  private _loadReadMap() {
+    try {
+      this.readMapSource.next(JSON.parse(localStorage.getItem(this.LOCAL_STORAGE_READ_KEY)) || {});
+    } catch {
+      this.readMapSource.next({});
+    }
+  }
+
+  private _persistReadMap(readMap: Record<string, boolean>) {
+    localStorage.setItem(this.LOCAL_STORAGE_READ_KEY, JSON.stringify(readMap));
+  }
+
+  private _recomputeHasUnread(notifications: CdNotification[]) {
+    const readMap = this.readMapSource.getValue();
+    this.hasUnreadSource.next(notifications.some((n) => !readMap[n.id]));
   }
 
   private _loadMutedState() {
@@ -89,7 +110,7 @@ export class NotificationService {
       localStorage.removeItem(this.LOCAL_STORAGE_KEY);
       localStorage.setItem(this.LOCAL_STORAGE_KEY, JSON.stringify(fallback));
       this.dataSource.next(fallback);
-      this.hasUnreadSource.next(fallback?.length > 0);
+      this._recomputeHasUnread(fallback);
     }
   }
 
@@ -115,7 +136,7 @@ export class NotificationService {
       .slice(0, this.MAX_NOTIFICATIONS);
 
     this.dataSource.next(limited);
-    this.hasUnreadSource.next(limited?.length > 0);
+    this._recomputeHasUnread(limited);
     this._persistNotifications(limited);
   }
 
@@ -126,7 +147,7 @@ export class NotificationService {
     const notifications = [...this.dataSource.getValue()];
     notifications.splice(index, 1);
     this.dataSource.next(notifications);
-    this.hasUnreadSource.next(notifications?.length > 0);
+    this._recomputeHasUnread(notifications);
     this._persistNotifications(notifications);
   }
 
@@ -135,6 +156,8 @@ export class NotificationService {
    */
   removeAll() {
     localStorage.removeItem(this.LOCAL_STORAGE_KEY);
+    localStorage.removeItem(this.LOCAL_STORAGE_READ_KEY);
+    this.readMapSource.next({});
     this.dataSource.next([]);
     this.hasUnreadSource.next(false);
     this._clearAllToasts();
@@ -351,4 +374,13 @@ export class NotificationService {
   getPanelState(): boolean {
     return this.panelState.value;
   }
+
+  markAsRead(id: string) {
+    const current = this.readMapSource.getValue();
+    if (current[id]) return;
+    const updated = { ...current, [id]: true };
+    this.readMapSource.next(updated);
+    this._persistReadMap(updated);
+    this._recomputeHasUnread(this.dataSource.getValue());
+  }
 }