mirror of
https://github.com/ceph/ceph
synced 2026-08-01 22:45:39 +00:00
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:
parent
a5fc039e7a
commit
36ceca6b08
@ -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
|
||||
]);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
@ -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);
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -4,3 +4,8 @@ export enum NotificationType {
|
||||
success,
|
||||
warning
|
||||
}
|
||||
|
||||
export enum NotificationApplication {
|
||||
Ceph = 'Ceph',
|
||||
Prometheus = 'Prometheus'
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user