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 <afreen@ibm.com>
This commit is contained in:
Afreen Misbah 2026-07-14 17:39:50 +05:30
parent a5fc039e7a
commit 36ceca6b08
18 changed files with 935 additions and 906 deletions

View 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
]);
}

View 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
@ -31,12 +31,12 @@
{{ 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>
}
}
@ -51,45 +51,25 @@
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
@ -108,7 +88,7 @@
@if (previousNotifications.length > 0) {
<div
class="notification-section-heading"
class="notification-section-heading cds--type-heading-compact-01"
i18n
>
Previous
@ -126,7 +106,7 @@
}
@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>
}

View 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;
@ -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));
}
.notification-close {
position: absolute;
right: 0;
top: $spacing-03;
padding: $spacing-02;
min-height: 0;
color: $text-helper;
cd-notification-item {
flex: 1;
min-width: 0;
}
.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);
}

View 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();
});
});

View 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);
}
}
}

View File

@ -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>

View File

@ -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);
}

View File

@ -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();
});
});

View File

@ -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);
}
}
}

View 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>

View File

@ -1,301 +1,120 @@
<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]"
>
<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"
>
<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>
</cd-notification-item>
</div>
</div>
} @empty {
<div class="notifications-page__empty">
<cd-icon
type="notification"
[size]="iconSize.size20"
[useDefault]="true"
></cd-icon>
<p
class="cds-mt-3"
i18n
>
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
>
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>

View File

@ -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-page {
--cd-notification-item-icon-offset: calc(-1 * var(--cds-spacing-02));
// Notifications list
.notifications-list {
cds-list-row {
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);
}
&__header-action {
display: inline-flex;
align-items: center;
gap: var(--cds-spacing-02);
&:last-child {
justify-self: end;
}
}
&__header-title {
text-align: center;
color: var(--cds-text-primary);
margin: 0;
}
&__body {
display: grid;
grid-template-columns: minmax(20rem, 1fr) 2fr;
overflow: hidden;
}
&__list {
overflow-y: auto;
background-color: var(--cds-layer-01);
}
&__item {
padding: var(--cds-spacing-03) var(--cds-spacing-05);
cursor: pointer;
transition: background-color 0.2s ease;
transition: background-color 0.15s ease;
&:hover {
background-color: var(--cds-layer-hover);
}
&.active {
&--active {
background-color: var(--cds-layer-selected);
}
.notification-title {
color: var(--cds-text-primary);
font-weight: var(--cds-font-weight-semibold);
}
&--unread &-title {
font-weight: var(--cds-font-weight-semibold);
}
}
}
// 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);
color: var(--cds-text-primary);
&__item .cd-notification-item__title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.notification-meta {
color: var(--cds-text-secondary);
font-size: var(--cds-label-01-font-size);
line-height: var(--cds-label-01-line-height);
&__detail {
overflow-y: auto;
padding: var(--cds-spacing-06) var(--cds-spacing-07);
background-color: var(--cds-layer-01);
}
}
.notification-date {
color: var(--cds-text-secondary);
font-size: var(--cds-label-01-font-size);
white-space: nowrap;
}
&__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);
}
}
// Empty state
.empty-state {
display: flex;
align-items: center;
justify-content: center;
color: var(--cds-text-secondary);
margin-top: var(--cds-spacing-05);
p {
&__detail-text {
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);
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);
}
}
// Notification details
.notification-details {
padding: var(--cds-spacing-05);
border-radius: var(--cds-border-radius);
background-color: var(--cds-layer-01);
}
// Details list
.details-list {
cds-list-row {
border-bottom: 1px solid var(--cds-border-subtle);
&:last-child {
border-bottom: none;
}
&:hover {
background-color: transparent;
}
}
.detail-label {
color: var(--cds-text-secondary);
font-weight: var(--cds-font-weight-semibold);
font-size: var(--cds-body-compact-01-font-size);
}
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);
}
&__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--cds-spacing-09) 0;
color: var(--cds-text-secondary);
}
&__empty cd-icon svg {
fill: var(--cds-icon-secondary);
}
}
// 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);
}
// Spacing utilities
.mb-4 {
margin-bottom: var(--cds-spacing-05);
}
.mt-4 {
margin-top: var(--cds-spacing-05);
}
.mt-5 {
margin-top: var(--cds-spacing-06);
}

View 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);
});
it('should be case insensitive', () => {
component.onSearch('SUCCESS');
expect(component.filteredNotifications.length).toBe(1);
expect(component.filteredNotifications[0].title).toBe('Success Notification');
describe('back navigation', () => {
it('should call location.back()', () => {
component.goBack();
expect(mockLocation.back).toHaveBeenCalled();
});
});
describe('notification removal', () => {
it('should remove notification', () => {
const notification = mockNotifications[0];
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('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;
component.selectedNotificationID.set('1');
const mockEvent = { stopPropagation: jasmine.createSpy() } as any;
component.removeNotification(component.notifications()[0], mockEvent);
expect(component.selectedNotificationID()).toBeNull();
});
const mockEvent = {
stopPropagation: jasmine.createSpy('stopPropagation'),
preventDefault: jasmine.createSpy('preventDefault')
};
component.removeNotification(notification, mockEvent as any);
const selectedNotification = component.selectedNotification;
expect(selectedNotification).toBeUndefined();
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');
});
});
describe('icon handling', () => {
it('should return correct Carbon icon for success', () => {
expect(component.getCarbonIcon(NotificationType.success)).toBe('checkmark--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 correct Carbon icon for error', () => {
expect(component.getCarbonIcon(NotificationType.error)).toBe('error--filled');
});
it('should return correct Carbon icon for info', () => {
expect(component.getCarbonIcon(NotificationType.info)).toBe('information--filled');
});
it('should return correct Carbon icon for warning', () => {
expect(component.getCarbonIcon(NotificationType.warning)).toBe('warning--filled');
});
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');
});
});
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 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%');
});
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);

View 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';
onNotificationDeleted(notificationId: string): void {
if (this.selectedNotificationID() === notificationId) {
this.selectedNotificationID.set(null);
}
}
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 '';
}
}
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;
}
}

View 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;
}

View 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 = {

View File

@ -4,3 +4,8 @@ export enum NotificationType {
success,
warning
}
export enum NotificationApplication {
Ceph = 'Ceph',
Prometheus = 'Prometheus'
}

View 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());
}
}