From: Afreen Misbah Date: Tue, 14 Jul 2026 12:09:50 +0000 (+0530) Subject: mgr/dashboard: redesign notifications page with master-detail layout X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=36ceca6b08b95567360eafa87f5980881550ec7e;p=ceph.git mgr/dashboard: redesign notifications page with master-detail layout - 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 --- diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation.module.ts index 67e1ea0b6a2..93f2fe65ed4 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation.module.ts @@ -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 ]); } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.html index 8cc60cc80c2..27a30ab40ad 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.html @@ -1,7 +1,7 @@
@if (executingTasks.length > 0) {
Running tasks @@ -31,12 +31,12 @@ {{ task.begin_time | relativeDate }} - {{ task.progress || 0 }} % + {{ task.progress || 0 }} %
-
+
} } @@ -51,45 +51,25 @@ class="notification-item" data-testid="notification-item" > - - - -
-
- {{ notification.timestamp | relativeDate }} -
-
- {{ notification.title }} -
-
-
- - + @if (!last) { -
+
} @if (todayNotifications.length > 0) {
Today @@ -108,7 +88,7 @@ @if (previousNotifications.length > 0) {
Previous @@ -126,7 +106,7 @@ } @if (todayNotifications.length === 0 && previousNotifications.length === 0) { -
+
No notifications
} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.scss index 86bd9d74e52..28569607d15 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.scss @@ -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; @@ -11,12 +6,10 @@ } .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; @@ -31,95 +24,66 @@ } .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; } } @@ -128,14 +92,12 @@ 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); } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.spec.ts index 6731eed237e..36298f4b109 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.spec.ts @@ -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; - let notificationService: any; let mockDataSource: BehaviorSubject; 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(); - }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.ts index a767f6be301..cd323d29d92 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.ts @@ -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 index 00000000000..729361ed41d --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-item/notification-item.component.html @@ -0,0 +1,80 @@ + +@if (showChevron) { + +} @else { + +} + +
+ + @if (timestamp) { + + {{ timestamp | relativeDate: true }} + + } + + @if (title) { + {{ title }} + } + + @if (message) { + @if (!showChevron) { + + } @else { +

+ {{ message }} +

+ } + } +
+ +
+ + @if (application) { + + + {{ timestamp | date: 'MMM d, y, h:mm a' }} + + } + + @if (showChevron) { + + + + } @else { + + + + } +
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 index 00000000000..94d7b75d64d --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-item/notification-item.component.scss @@ -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 index 00000000000..12d17660aff --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-item/notification-item.component.spec.ts @@ -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: ` + + + `, + 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; + 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 index 00000000000..7e0d01953d1 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-item/notification-item.component.ts @@ -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(); + + 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); + } + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-panel/notification-panel.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-panel/notification-panel.component.html index 4b614761a3c..f36586c567a 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-panel/notification-panel.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-panel/notification-panel.component.html @@ -1,7 +1,4 @@ -
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.html index d81e7dd6fd8..1945ac7474f 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.html @@ -1,301 +1,120 @@ -
- -
- - -
+ Back + +

-
+ Notifications +

+ + Clear all + + + +
+
+ @for (notification of notifications(); track notification.id) {
-

{{ selectedNotification.title }}

- + +
+ } @empty { +
+ +

- - Application: - {{ selectedNotification.application }} - - - Type: - - - {{ - selectedNotification.type === 0 - ? 'Error' - : selectedNotification.type === 1 - ? 'Info' - : selectedNotification.type === 2 - ? 'Success' - : 'Warning' - }} - - - - - Date: - {{ formatDate(selectedNotification.timestamp) }} - - - Time: - {{ formatTime(selectedNotification.timestamp) }} - - - - - Alert Name: - {{ - selectedNotification.prometheusAlert.alertName - }} - - - Status: - - - {{ selectedNotification.prometheusAlert.status }} - - - - - Severity: - - - {{ selectedNotification.prometheusAlert.severity }} - - - - - Instance: - {{ - selectedNotification.prometheusAlert.instance - }} - - - Job: - {{ selectedNotification.prometheusAlert.job }} - - - Description: - -

- {{ selectedNotification.prometheusAlert.description }} -

- - - - Source: - - - - View in Prometheus - - - - - Fingerprint: - - {{ - selectedNotification.prometheusAlert.fingerprint - }} - - - - - +
+ } +
+ +
+ @if (selectedNotification(); as selected) { +
+ +
+
+

+ {{ selected.displayPreview }} +

+ @if (selected.prometheusAlert?.sourceUrl) { + - Message: - -

{{ selectedNotification.message }}

-
- - + View in Prometheus +
+ } +
+ } @else { +
+ +

+ Select a notification to view details +

-
+ }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.scss index b37aacd7325..4d5d87697f7 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.scss @@ -1,175 +1,99 @@ -// 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); + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.spec.ts index dc08230b511..c91d906ef22 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.spec.ts @@ -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; let mockNotifications: CdNotification[]; let dataSourceSubject: BehaviorSubject; + let readMapSubject: BehaviorSubject>; let notificationService: any; + let mockLocation: any; - // Create mocks const createMockNotificationService = () => { dataSourceSubject = new BehaviorSubject([]); + readMapSubject = new BehaviorSubject>({}); 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); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.ts index c19181bf26f..a250e2f15c1 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.ts @@ -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([]); + selectedNotificationID = signal(null); + readMap: ReturnType>>; + + 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 + }); + } 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; - } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/icon/icon.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/icon/icon.component.scss index a702d0fe041..34759909294 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/icon/icon.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/icon/icon.component.scss @@ -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; } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts index 22a047d462e..fbccf2e940e 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts @@ -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 = { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/notification-type.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/notification-type.enum.ts index 7fd9e828392..b631264d8ea 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/notification-type.enum.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/notification-type.enum.ts @@ -4,3 +4,8 @@ export enum NotificationType { success, warning } + +export enum NotificationApplication { + Ceph = 'Ceph', + Prometheus = 'Prometheus' +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.ts index 017e6ae7191..9da0326b4ee 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.ts @@ -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([]); private panelState = new BehaviorSubject(false); private muteStateSource = new BehaviorSubject(false); private activeToastsSource = new BehaviorSubject([]); private hasUnreadSource = new BehaviorSubject(false); + private readMapSource = new BehaviorSubject>({}); 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) { + 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()); + } }