ProgressBarModule,
StructuredListModule,
SearchModule,
- ModalModule
+ ModalModule,
+ LinkModule
} from 'carbon-components-angular';
import { AppRoutingModule } from '~/app/app-routing.module';
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: [
ProgressBarModule,
StructuredListModule,
SearchModule,
- ModalModule
+ ModalModule,
+ LinkModule
],
declarations: [
AboutComponent,
NotificationHeaderComponent,
NotificationAreaComponent,
NotificationFooterComponent,
+ NotificationItemComponent,
NotificationsPageComponent,
DashboardHelpComponent,
AdministrationComponent,
CircleDashIcon20,
CheckmarkOutlineIcon20,
ArrowRightIcon,
+ ArrowLeftIcon,
+ ChevronRightIcon,
VmdkDiskIcon
]);
}
<div class="notification-area">
@if (executingTasks.length > 0) {
<div
- class="notification-section-heading"
+ class="notification-section-heading cds--type-heading-compact-01"
i18n
>
Running tasks
{{ task.begin_time | relativeDate }}
</span>
- <span class="task-progress"> {{ task.progress || 0 }} % </span>
+ <span class="task-progress cds--type-label-01"> {{ task.progress || 0 }} % </span>
</div>
</div>
</div>
- <div class="notification-divider"></div>
+ <div class="border-subtle-bottom"></div>
</div>
}
}
class="notification-item"
data-testid="notification-item"
>
- <cd-icon
- id="notification-icon"
- [type]="notificationIconMap[notification.type] || notificationIconMap['default']"
+ <cd-notification-item
+ [type]="notification.type"
+ [title]="notification.title"
+ [timestamp]="notification.timestamp"
+ [message]="notification.message"
+ [notificationId]="notification.id"
>
- </cd-icon>
-
- <div class="notification-content">
- <div class="notification-timestamp">
- {{ notification.timestamp | relativeDate }}
- </div>
- <div class="notification-title">
- {{ notification.title }}
- </div>
- <div
- class="notification-message"
- [innerHTML]="notification.message | sanitizeHtml"
- ></div>
- </div>
-
- <button
- cdsButton="ghost"
- size="sm"
- class="notification-close"
- (click)="removeNotification(notification, $event)"
- type="button"
- >
- <cd-icon type="destroy"></cd-icon>
- </button>
+ </cd-notification-item>
</div>
@if (!last) {
- <div class="notification-divider"></div>
+ <div class="border-subtle-bottom"></div>
}
</div>
</ng-template>
@if (todayNotifications.length > 0) {
<div
- class="notification-section-heading"
+ class="notification-section-heading cds--type-heading-compact-01"
i18n
>
Today
@if (previousNotifications.length > 0) {
<div
- class="notification-section-heading"
+ class="notification-section-heading cds--type-heading-compact-01"
i18n
>
Previous
}
@if (todayNotifications.length === 0 && previousNotifications.length === 0) {
- <div class="notification-empty">
+ <div class="notification-empty cds--type-body-short-01">
<div i18n>No notifications</div>
</div>
}
-@use './src/styles/defaults' as *;
-@use '@carbon/styles/scss/theme' as *;
-@use '@carbon/styles/scss/spacing' as *;
-@use '@carbon/type';
-
-:host {
+cd-notification-area {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
.notification-section-heading {
- @include type.type-style('heading-compact-01');
-
margin: 0;
- color: $text-primary;
- padding: $spacing-05 $spacing-05 $spacing-03;
- background-color: $layer-01;
+ color: var(--cds-text-primary);
+ padding: var(--cds-spacing-05) var(--cds-spacing-05) var(--cds-spacing-03);
+ background-color: var(--cds-layer-01);
position: sticky;
z-index: 2;
top: 0;
}
.task-progress {
- @include type.type-style('label-01');
-
color: var(--cds-text-secondary);
text-align: right;
}
.notification-timestamp {
- @include type.type-style('label-01');
-
- color: $text-secondary;
+ color: var(--cds-text-secondary);
line-height: 1;
margin-top: 0;
display: block;
}
.notification-title {
- @include type.type-style('body-short-01');
-
margin: 0;
- color: $text-primary;
+ color: var(--cds-text-primary);
line-height: 1.25;
display: block;
overflow-wrap: break-word;
}
-.notification-message {
- @include type.type-style('body-short-01');
-
- color: $text-helper;
- margin: 0;
- line-height: 1.4;
- margin-top: -$spacing-01;
- display: -webkit-box;
- -webkit-line-clamp: 2;
- -webkit-box-orient: vertical;
- overflow: hidden;
- overflow-wrap: break-word;
-}
-
.notification-empty {
margin: 0;
- padding: $spacing-05;
- color: $text-secondary;
+ padding: var(--cds-spacing-05);
+ color: var(--cds-text-secondary);
text-align: center;
-
- @include type.type-style('body-short-01');
}
.notification-wrapper {
- padding: 0 $spacing-05;
+ padding: 0 var(--cds-spacing-05);
position: relative;
z-index: 1;
- background-color: $layer-01;
+ background-color: var(--cds-layer-01);
&:last-child {
- padding-bottom: $spacing-05;
+ padding-bottom: var(--cds-spacing-05);
}
}
.notification-item {
display: flex;
- gap: $spacing-05;
- padding: $spacing-03 0;
+ gap: var(--cds-spacing-05);
+ padding: var(--cds-spacing-03) 0;
align-items: flex-start;
position: relative;
> cd-icon {
- margin-top: -$spacing-03;
+ margin-top: calc(-1 * var(--cds-spacing-03));
+ }
+
+ cd-notification-item {
+ flex: 1;
+ min-width: 0;
}
- .notification-close {
- position: absolute;
- right: 0;
- top: $spacing-03;
- padding: $spacing-02;
- min-height: 0;
- color: $text-helper;
+ .cd-notification-item__delete {
opacity: 0;
transition: opacity 0.2s ease-in-out;
-
- &:hover {
- background-color: $layer-hover;
- }
}
- &:hover {
- .notification-close {
- opacity: 1;
- }
+ &:hover .cd-notification-item__delete {
+ opacity: 1;
}
}
min-width: 0;
display: flex;
flex-direction: column;
- gap: $spacing-02;
- padding-right: $spacing-06;
-}
-
-.notification-divider {
- border-bottom: 1px solid $border-subtle-01;
+ gap: var(--cds-spacing-02);
+ padding-right: var(--cds-spacing-06);
}
.notification-area {
- background-color: $layer-01;
+ --cd-notification-item-icon-offset: calc(-1 * var(--cds-spacing-02));
+
+ background-color: var(--cds-layer-01);
}
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';
describe('NotificationAreaComponent', () => {
let component: NotificationAreaComponent;
let fixture: ComponentFixture<NotificationAreaComponent>;
- let notificationService: any;
let mockDataSource: BehaviorSubject<CdNotification[]>;
const today = new Date();
configureTestBed({
imports: [SharedModule, NoopAnimationsModule],
- declarations: [NotificationAreaComponent]
+ declarations: [NotificationAreaComponent, NotificationItemComponent]
});
beforeEach(() => {
TestBed.overrideProvider(NotificationService, { useValue: spy });
fixture = TestBed.createComponent(NotificationAreaComponent);
component = fixture.componentInstance;
- notificationService = TestBed.inject(NotificationService);
fixture.detectChanges();
});
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();
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', () => {
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();
});
});
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', () => {
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();
- });
});
-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';
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 {
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,
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);
- }
- }
}
--- /dev/null
+<!-- 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>
--- /dev/null
+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);
+}
--- /dev/null
+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();
+ });
+});
--- /dev/null
+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);
+ }
+ }
+}
-<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>
-<div
- cdsGrid
- [useCssGrid]="false"
- [fullWidth]="true"
- class="notifications-page__container"
->
- <!-- Left Panel - Notifications List -->
- <div cdsRow>
- <div
- cdsCol
- [columnNumbers]="{ sm: 4, md: 4, lg: 6 }"
+<div class="notifications-page">
+ <header class="notifications-page__header border-subtle-bottom">
+ <a
+ cdsLink
+ class="notifications-page__header-action"
+ tabindex="0"
+ (click)="goBack()"
+ (keydown.enter)="goBack()"
>
- <cds-search
- [size]="'md'"
- placeholder="Search notifications..."
- i18n-placeholder
- [(ngModel)]="searchText"
- (valueChange)="onSearch($event)"
- >
- </cds-search>
- <cds-structured-list
- class="notifications-list"
- *ngIf="filteredNotifications.length > 0"
- >
- <cds-list-row
- *ngFor="let notification of filteredNotifications; trackBy: trackByNotificationId"
- [class.active]="selectedNotificationID === notification.id"
- (click)="onNotificationSelect(notification)"
- class="notification-row"
- >
- <cds-list-column>
- <div class="notification-item-content">
- <h6 class="notification-title">
- <ng-container *ngIf="notification.prometheusAlert; else regularTitle">
- {{ notification.prometheusAlert.alertName }}
- <cds-tag
- [type]="
- notification.prometheusAlert.status === 'active' ||
- notification.prometheusAlert.status === 'firing'
- ? 'red'
- : notification.prometheusAlert.status === 'resolved'
- ? 'green'
- : 'blue'
- "
- [size]="'sm'"
- class="ml-2"
- >
- {{ notification.prometheusAlert.status }}
- </cds-tag>
- </ng-container>
- <ng-template #regularTitle>
- {{ notification.title }}
- </ng-template>
- </h6>
- <small class="notification-meta">
- {{ notification.application }}
- <ng-container *ngIf="notification.prometheusAlert">
- • {{ notification.prometheusAlert.severity }}
- <ng-container *ngIf="notification.prometheusAlert.instance">
- • {{ notification.prometheusAlert.instance }}
- </ng-container>
- </ng-container>
- </small>
- </div>
- </cds-list-column>
- <cds-list-column nowrap="true">
- <small class="notification-date">{{ formatDate(notification.timestamp) }}</small>
- </cds-list-column>
- </cds-list-row>
- </cds-structured-list>
- <div
- *ngIf="filteredNotifications.length === 0"
- class="empty-state"
- cdsRow
- >
- <svg
- cdsIcon="notification"
- size="20"
- class="empty-icon"
- ></svg>
- <p *ngIf="searchText">No notifications match your search</p>
- <p *ngIf="!searchText">No notifications available</p>
- </div>
- </div>
- <!-- Right Panel - Notifications Details -->
- <div
- cdsCol
- [columnNumbers]="{ sm: 12, md: 12, lg: 10 }"
+ <cd-icon
+ type="left"
+ [useDefault]="true"
+ ></cd-icon>
+ <span i18n>Back</span>
+ </a>
+ <h4
+ class="notifications-page__header-title cds--type-heading-03"
+ i18n
>
- <div
- cdsGrid
- [fullWidth]="true"
- *ngIf="selectedNotification"
- class="notification-details"
- >
+ Notifications
+ </h4>
+ <a
+ cdsLink
+ class="notifications-page__header-action"
+ tabindex="0"
+ (click)="clearAll()"
+ (keydown.enter)="clearAll()"
+ i18n
+ >
+ Clear all
+ </a>
+ </header>
+
+ <div class="notifications-page__body">
+ <div class="notifications-page__list border-subtle-right">
+ @for (notification of notifications(); track notification.id) {
<div
- cdsCol
- [columnNumbers]="{ sm: 16, md: 16, lg: 16 }"
+ class="notifications-page__item border-subtle-bottom"
+ [class.notifications-page__item--active]="selectedNotificationID() === notification.id"
+ [class.notifications-page__item--unread]="!readMap()[notification.id]"
+ role="button"
+ tabindex="0"
+ (click)="onNotificationSelect(notification)"
+ (keydown.enter)="onNotificationSelect(notification)"
>
- <h3 cdsRow>{{ selectedNotification.title }}</h3>
- <cds-structured-list
- cdsRow
- class="details-list mt-4"
+ <cd-notification-item
+ [type]="notification.type"
+ [title]="notification.displayTitle"
+ [timestamp]="notification.timestamp"
+ [message]="notification.displayPreview"
+ [notificationId]="notification.id"
+ [showChevron]="true"
+ [showUnread]="!readMap()[notification.id]"
+ >
+ </cd-notification-item>
+ </div>
+ } @empty {
+ <div class="notifications-page__empty">
+ <cd-icon
+ type="notification"
+ [size]="iconSize.size20"
+ [useDefault]="true"
+ ></cd-icon>
+ <p
+ class="cds-mt-3"
+ i18n
>
- <cds-list-row>
- <cds-list-column
- nowrap="true"
- class="detail-label"
- >Application:</cds-list-column
- >
- <cds-list-column>{{ selectedNotification.application }}</cds-list-column>
- </cds-list-row>
- <cds-list-row>
- <cds-list-column
- nowrap="true"
- class="detail-label"
- >Type:</cds-list-column
- >
- <cds-list-column>
- <cds-tag
- [type]="
- selectedNotification.type === 0
- ? 'red'
- : selectedNotification.type === 1
- ? 'blue'
- : selectedNotification.type === 2
- ? 'green'
- : 'yellow'
- "
- [size]="'sm'"
- >
- {{
- selectedNotification.type === 0
- ? 'Error'
- : selectedNotification.type === 1
- ? 'Info'
- : selectedNotification.type === 2
- ? 'Success'
- : 'Warning'
- }}
- </cds-tag>
- </cds-list-column>
- </cds-list-row>
- <cds-list-row>
- <cds-list-column
- nowrap="true"
- class="detail-label"
- >Date:</cds-list-column
- >
- <cds-list-column>{{ formatDate(selectedNotification.timestamp) }}</cds-list-column>
- </cds-list-row>
- <cds-list-row>
- <cds-list-column
- nowrap="true"
- class="detail-label"
- >Time:</cds-list-column
- >
- <cds-list-column>{{ formatTime(selectedNotification.timestamp) }}</cds-list-column>
- </cds-list-row>
- <!-- Prometheus-specific metadata fields -->
- <ng-container *ngIf="selectedNotification.prometheusAlert">
- <cds-list-row>
- <cds-list-column
- nowrap="true"
- class="detail-label"
- >Alert Name:</cds-list-column
- >
- <cds-list-column>{{
- selectedNotification.prometheusAlert.alertName
- }}</cds-list-column>
- </cds-list-row>
- <cds-list-row>
- <cds-list-column
- nowrap="true"
- class="detail-label"
- >Status:</cds-list-column
- >
- <cds-list-column>
- <cds-tag
- [type]="
- selectedNotification.prometheusAlert.status === 'active' ||
- selectedNotification.prometheusAlert.status === 'firing'
- ? 'red'
- : selectedNotification.prometheusAlert.status === 'resolved'
- ? 'green'
- : selectedNotification.prometheusAlert.status === 'suppressed'
- ? 'gray'
- : 'blue'
- "
- [size]="'sm'"
- >
- {{ selectedNotification.prometheusAlert.status }}
- </cds-tag>
- </cds-list-column>
- </cds-list-row>
- <cds-list-row>
- <cds-list-column
- nowrap="true"
- class="detail-label"
- >Severity:</cds-list-column
- >
- <cds-list-column>
- <cds-tag
- [type]="
- selectedNotification.prometheusAlert.severity === 'critical'
- ? 'red'
- : selectedNotification.prometheusAlert.severity === 'warning'
- ? 'yellow'
- : selectedNotification.prometheusAlert.severity === 'resolved'
- ? 'green'
- : 'blue'
- "
- [size]="'sm'"
- >
- {{ selectedNotification.prometheusAlert.severity }}
- </cds-tag>
- </cds-list-column>
- </cds-list-row>
- <cds-list-row *ngIf="selectedNotification.prometheusAlert.instance">
- <cds-list-column
- nowrap="true"
- class="detail-label"
- >Instance:</cds-list-column
- >
- <cds-list-column>{{
- selectedNotification.prometheusAlert.instance
- }}</cds-list-column>
- </cds-list-row>
- <cds-list-row *ngIf="selectedNotification.prometheusAlert.job">
- <cds-list-column
- nowrap="true"
- class="detail-label"
- >Job:</cds-list-column
- >
- <cds-list-column>{{ selectedNotification.prometheusAlert.job }}</cds-list-column>
- </cds-list-row>
- <cds-list-row>
- <cds-list-column
- nowrap="true"
- class="detail-label"
- >Description:</cds-list-column
- >
- <cds-list-column>
- <p class="message-content">
- {{ selectedNotification.prometheusAlert.description }}
- </p>
- </cds-list-column>
- </cds-list-row>
- <cds-list-row *ngIf="selectedNotification.prometheusAlert.sourceUrl">
- <cds-list-column
- nowrap="true"
- class="detail-label"
- >Source:</cds-list-column
- >
- <cds-list-column>
- <a
- [href]="selectedNotification.prometheusAlert.sourceUrl"
- target="_blank"
- class="source-link"
- >
- <svg
- cdsIcon="launch"
- size="16"
- ></svg>
- View in Prometheus
- </a>
- </cds-list-column>
- </cds-list-row>
- <cds-list-row *ngIf="selectedNotification.prometheusAlert.fingerprint">
- <cds-list-column
- nowrap="true"
- class="detail-label"
- >Fingerprint:</cds-list-column
- >
- <cds-list-column>
- <code class="fingerprint">{{
- selectedNotification.prometheusAlert.fingerprint
- }}</code>
- </cds-list-column>
- </cds-list-row>
- </ng-container>
- <!-- Regular message for non-Prometheus notifications -->
- <cds-list-row
- cdsRow
- *ngIf="selectedNotification.message && !selectedNotification.prometheusAlert"
+ No notifications available
+ </p>
+ </div>
+ }
+ </div>
+
+ <div class="notifications-page__detail">
+ @if (selectedNotification(); as selected) {
+ <div class="notifications-page__detail-header">
+ <cd-notification-item
+ [type]="selected.type"
+ [title]="selected.displayTitle"
+ [timestamp]="selected.timestamp"
+ [notificationId]="selected.id"
+ [application]="selected.application"
+ (deleted)="onNotificationDeleted($event)"
+ ></cd-notification-item>
+ </div>
+ <div class="notifications-page__detail-body cds-mt-5">
+ <p class="notifications-page__detail-text cds--type-body-01">
+ {{ selected.displayPreview }}
+ </p>
+ @if (selected.prometheusAlert?.sourceUrl) {
+ <a
+ cdsLink
+ class="cds-mt-5"
+ [href]="selected.prometheusAlert.sourceUrl"
+ target="_blank"
+ rel="noopener noreferrer"
+ i18n
>
- <cds-list-column
- nowrap="true"
- class="detail-label"
- >Message:</cds-list-column
- >
- <cds-list-column>
- <p class="message-content">{{ selectedNotification.message }}</p>
- </cds-list-column>
- </cds-list-row>
- </cds-structured-list>
+ View in Prometheus
+ </a>
+ }
+ </div>
+ } @else {
+ <div class="notifications-page__empty">
+ <cd-icon
+ type="notification"
+ [size]="iconSize.size20"
+ [useDefault]="true"
+ ></cd-icon>
+ <p
+ class="cds-mt-3"
+ i18n
+ >
+ Select a notification to view details
+ </p>
</div>
- </div>
+ }
</div>
</div>
</div>
-// 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);
+ }
}
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';
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));
+ }
+ })
};
};
})
};
- const mockChangeDetectorRef = {
- detectChanges: jasmine.createSpy('detectChanges')
- };
-
- // Create mock notifications
const createMockNotification = (overrides: any): CdNotification => {
return {
id: overrides.id,
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;
})
];
+ 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();
});
if (component['interval']) {
window.clearInterval(component['interval']);
}
+ localStorage.removeItem('cdNotificationsRead');
});
it('should create', () => {
});
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('');
});
});
configOpt: { read: true }
});
- // Re-initialize component to trigger ngOnInit with new permissions
fixture = TestBed.createComponent(NotificationsPageComponent);
component = fixture.componentInstance;
dataSourceSubject.next(mockNotifications);
-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',
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;
private notificationService: NotificationService,
private prometheusAlertService: PrometheusAlertService,
private prometheusNotificationService: PrometheusNotificationService,
- private authStorageService: AuthStorageService
- ) {}
+ private authStorageService: AuthStorageService,
+ private location: Location
+ ) {
+ this.readMap = toSignal(this.notificationService.readMap$, {
+ initialValue: {} as Record<string, boolean>
+ });
+ }
ngOnInit(): void {
- // Check permissions and trigger Prometheus alerts refresh
const permissions = this.authStorageService.getPermissions();
if (permissions.prometheus.read && permissions.configOpt.read) {
this.triggerPrometheusAlerts();
- // Set up periodic refresh similar to sidebar component
this.interval = window.setInterval(() => {
this.triggerPrometheusAlerts();
}, 5000);
}
- // Subscribe to notifications from the service
this.sub = this.notificationService.data$.subscribe((notifications) => {
- this.notifications = notifications;
-
- // preserve filtered array reference if search active
- if (!this.searchText) {
- this.filteredNotifications = notifications;
- } else {
- this.onSearch(this.searchText);
- }
+ this.notifications.set(
+ notifications.map((n) =>
+ Object.assign(n, {
+ displayTitle: n.prometheusAlert?.alertName || n.title || '',
+ displayPreview: n.prometheusAlert?.description || n.message || ''
+ })
+ )
+ );
});
}
ngOnDestroy(): void {
- if (this.sub) {
- this.sub.unsubscribe();
- }
+ this.sub?.unsubscribe();
if (this.interval) {
window.clearInterval(this.interval);
}
}
- onNotificationSelect(notification: CdNotification): void {
- this.selectedNotificationID = notification.id;
+ goBack(): void {
+ this.location.back();
}
- get selectedNotification(): CdNotification {
- return this.filteredNotifications.find((n) => n.id === this.selectedNotificationID);
+ clearAll(): void {
+ this.notificationService.removeAll();
+ this.selectedNotificationID.set(null);
}
- onSearch(value: string): void {
- this.searchText = value;
- if (!value || value.trim() === '') {
- this.filteredNotifications = this.notifications;
- } else {
- const searchLower = value.toLowerCase();
- this.filteredNotifications = this.notifications.filter(
- (notification) =>
- notification.title?.toLowerCase().includes(searchLower) ||
- notification.message?.toLowerCase().includes(searchLower) ||
- notification.application?.toLowerCase().includes(searchLower)
- );
- }
+ onNotificationSelect(notification: DisplayNotification): void {
+ this.selectedNotificationID.set(notification.id);
+ this.notificationService.markAsRead(notification.id);
}
- removeNotification(notification: CdNotification, event: MouseEvent): void {
- // Stop event propagation to prevent panel closing
+ removeNotification(notification: DisplayNotification, event: MouseEvent): void {
event.stopPropagation();
- event.preventDefault();
-
- // Get the notification index from the service's data
- const notifications = this.notificationService['dataSource'].getValue();
- const index = notifications.findIndex((n) => n.id === notification.id);
-
+ const index = this.notifications().findIndex((n) => n.id === notification.id);
if (index > -1) {
- // Remove the notification through the service
this.notificationService.remove(index);
-
- // Clear selection if the removed notification was selected
- if (this.selectedNotificationID === notification.id) {
- this.selectedNotificationID = null;
+ if (this.selectedNotificationID() === notification.id) {
+ this.selectedNotificationID.set(null);
}
}
}
- getCarbonIcon(type: NotificationType | string): string {
- switch (type) {
- case NotificationType.success:
- return 'checkmark--filled';
- case NotificationType.error:
- return 'error--filled';
- case NotificationType.info:
- return 'information--filled';
- case NotificationType.warning:
- return 'warning--filled';
- default:
- return 'notification--filled';
- }
- }
-
- getIconColorClass(type: NotificationType | string): string {
- switch (type) {
- case NotificationType.success:
- return 'icon-success';
- case NotificationType.error:
- return 'icon-error';
- case NotificationType.info:
- return 'icon-info';
- case NotificationType.warning:
- return 'icon-warning';
- default:
- return '';
+ onNotificationDeleted(notificationId: string): void {
+ if (this.selectedNotificationID() === notificationId) {
+ this.selectedNotificationID.set(null);
}
}
- formatDate(timestamp: string): string {
- const date = new Date(timestamp);
- const today = new Date();
- const yesterday = new Date(today);
- yesterday.setDate(yesterday.getDate() - 1);
-
- if (date.toDateString() === today.toDateString()) {
- return 'Today';
- } else if (date.toDateString() === yesterday.toDateString()) {
- return 'Yesterday';
- } else {
- return date.toLocaleDateString('en-US', {
- month: 'short',
- day: 'numeric'
- });
- }
- }
-
- formatTime(timestamp: string): string {
- const date = new Date(timestamp);
- return date.toLocaleTimeString('en-US', {
- hour: 'numeric',
- minute: '2-digit',
- hour12: true
- });
- }
-
private triggerPrometheusAlerts(): void {
this.prometheusAlertService.refresh();
this.prometheusNotificationService.refresh();
}
-
- trackByNotificationId(_index: number, notification: CdNotification): string {
- return notification.id;
- }
}
fill: theme.$layer-selected-disabled !important;
}
-.notificationNew-icon circle {
- fill: theme.$support-error !important;
-}
-
.emptySearch-icon {
fill: theme.$layer-selected-disabled !important;
}
locked: 'locked',
cloudMonitoring: 'cloud--monitoring',
trash: 'trash-can',
- temperature: 'temperature'
+ temperature: 'temperature',
+ left: 'arrow--left'
} as const;
export const EMPTY_STATE_IMAGE = {
success,
warning
}
+
+export enum NotificationApplication {
+ Ceph = 'Ceph',
+ Prometheus = 'Prometheus'
+}
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[] = [];
private cdDatePipe: CdDatePipe,
private ngZone: NgZone
) {
+ this._loadReadMap();
this._loadStoredNotifications();
this._loadMutedState();
}
}
}
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() {
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);
}
}
.slice(0, this.MAX_NOTIFICATIONS);
this.dataSource.next(limited);
- this.hasUnreadSource.next(limited?.length > 0);
+ this._recomputeHasUnread(limited);
this._persistNotifications(limited);
}
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);
}
*/
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();
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());
+ }
}