mgr/dashboard: Sanitize toast HTML and optimize notification service

- Escape user-controlled content before Carbon innerHTML rendering
- Whitelist localStorage deserialization keys, prune stale read map
- Replace deep equality with ID comparison in removeToast
- Gate ngAfterViewChecked DOM queries behind a dirty flag
- Deduplicate by title+type+message; show occurrences count
- Fix bell icon unread dot clearing on new occurrences
- Fix view more pre-selection via route queryParams observable
- Save individual notifications instead of HTML-merged messages
- Convert notifications bell component to signals with OnPush
- Add OnPush to notification-item and toast components
- Add e2e tests
- Fix styling of notification panel

Signed-off-by: Afreen Misbah <afreen@ibm.com>
This commit is contained in:
Afreen Misbah 2026-07-21 18:01:36 +05:30
parent 8cb0932382
commit a9a6255cd2
17 changed files with 608 additions and 140 deletions

View File

@ -54,3 +54,243 @@ describe('Notification page', () => {
});
});
});
describe('Notification panel to page navigation', () => {
const notification = new NotificationSidebarPageHelper();
beforeEach(() => {
cy.login();
cy.visit('#/');
cy.wait(5000);
});
it('should navigate to notifications page via View all', () => {
notification.navigateToNotificationsPage();
cy.url().should('include', '/notifications');
notification.getNotificationsPage().should('exist');
});
it('should navigate from panel notification click to page with pre-selected detail', () => {
notification.open();
notification.getNotifications().then(($items) => {
if ($items.length === 0) {
cy.log('No notifications in panel — skipping');
return;
}
notification.getNotifications().first().click();
cy.url().should('include', '/notifications');
cy.url().should('include', 'id=');
notification.getNotificationsPage().should('exist');
notification.getNotificationsPageDetail().find('cd-notification-item').should('exist');
notification
.getNotificationsPageListItems()
.first()
.should('have.class', 'notifications-page__item--active');
});
});
it('should show back button and navigate back', () => {
notification.navigateToNotificationsPage();
cy.contains('Back').should('be.visible').click();
cy.url().should('not.include', '/notifications');
});
});
describe('Notifications page actions', () => {
const notification = new NotificationSidebarPageHelper();
beforeEach(() => {
cy.login();
cy.visit('#/');
cy.wait(3000);
});
it('should show kebab menu with mark all as read and clear all', () => {
notification.navigateToNotificationsPage();
notification.getKebabMenu().click();
notification.getKebabMenuItems().should('have.length', 2);
cy.contains('.cds--overflow-menu-options__btn', 'Mark all as read').should('exist');
cy.contains('.cds--overflow-menu-options__btn', 'Clear all').should('exist');
});
it('should show empty state when no notifications', () => {
notification.open();
notification.clearNotifications();
notification.getViewAllBtn().click({ force: true });
notification.getNotificationsPage().should('exist');
cy.contains('No notifications available').should('be.visible');
});
it('should disable kebab actions when no notifications', () => {
notification.open();
notification.clearNotifications();
notification.getViewAllBtn().click({ force: true });
notification.getKebabMenu().click();
cy.contains('.cds--overflow-menu-options__btn', 'Mark all as read').should('be.disabled');
cy.contains('.cds--overflow-menu-options__btn', 'Clear all').should('be.disabled');
});
});
describe('Notification selection and detail', () => {
const notification = new NotificationSidebarPageHelper();
beforeEach(() => {
cy.login();
cy.visit('#/');
cy.wait(5000);
});
it('should select a notification and show detail in right panel', () => {
notification.navigateToNotificationsPage();
notification.getNotificationsPageListItems().then(($items) => {
if ($items.length === 0) {
cy.log('No notifications to select — skipping');
return;
}
cy.wrap($items.first()).click();
cy.wrap($items.first()).should('have.class', 'notifications-page__item--active');
notification.getNotificationsPageDetail().find('cd-notification-item').should('exist');
notification
.getNotificationsPageDetail()
.find('.notifications-page__detail-text')
.should('exist');
});
});
it('should show occurrences count when notification has duplicates', () => {
notification.navigateToNotificationsPage();
notification.getNotificationsPageListItems().then(($items) => {
if ($items.length === 0) {
cy.log('No notifications — skipping');
return;
}
cy.wrap($items.first()).click();
notification.getNotificationsPageDetail().then(($detail) => {
const hasOccurrences = $detail.find('.notifications-page__occurrences').length > 0;
if (hasOccurrences) {
cy.get('.notifications-page__occurrences')
.should('be.visible')
.and('contain', 'Occurrences:');
} else {
cy.log('First notification has no duplicates — occurrences label correctly hidden');
}
});
});
});
it('should clear all notifications via kebab menu', () => {
notification.navigateToNotificationsPage();
notification.getNotificationsPageListItems().then(($items) => {
if ($items.length === 0) {
cy.log('No notifications to clear — skipping');
return;
}
notification.getKebabMenu().click();
cy.contains('.cds--overflow-menu-options__btn', 'Clear all').click();
cy.contains('No notifications available').should('be.visible');
});
});
});
describe('Notification toast deduplication', () => {
const notification = new NotificationSidebarPageHelper();
beforeEach(() => {
cy.login();
cy.visit('#/');
});
it('should show +N more count on duplicate toasts', () => {
cy.wait(8000);
cy.get('body').then(($body) => {
if ($body.find('.toast-duplicate-count').length > 0) {
notification
.getToastDuplicateCount()
.first()
.should('be.visible')
.invoke('text')
.should('match', /\(\+\d+ more\)/);
} else if ($body.find('cds-toast').length > 0) {
cy.log('Toasts present but no duplicates yet — only unique errors firing');
} else {
cy.log('No toasts appeared — skipping');
}
});
});
it('should show view more link when toast text is truncated', () => {
cy.wait(5000);
cy.get('body').then(($body) => {
if ($body.find('.toast-view-more:visible').length > 0) {
notification
.getToastViewMoreLink()
.first()
.should('be.visible')
.and('contain', 'View more');
} else {
cy.log('No truncated toasts with view more — skipping');
}
});
});
it('should navigate to notifications page and dismiss toasts on view more click', () => {
cy.wait(5000);
cy.get('body').then(($body) => {
if ($body.find('.toast-view-more:visible').length > 0) {
notification.getToastViewMoreLink().first().click({ force: true });
cy.url().should('include', '/notifications');
notification.getNotificationsPage().should('exist');
notification.getToasts().should('not.exist');
} else {
cy.log('No visible view more link — skipping');
}
});
});
});
describe('Notification read state', () => {
const notification = new NotificationSidebarPageHelper();
beforeEach(() => {
cy.login();
cy.visit('#/');
cy.wait(5000);
});
it('should mark notification as read on selection', () => {
notification.navigateToNotificationsPage();
notification.getNotificationsPageListItems().then(($items) => {
if ($items.length === 0) {
cy.log('No notifications — skipping');
return;
}
cy.wrap($items.first()).click();
cy.wrap($items.first()).should('not.have.class', 'notifications-page__item--unread');
});
});
it('should mark all as read via kebab menu', () => {
notification.navigateToNotificationsPage();
notification.getNotificationsPageListItems().then(($items) => {
if ($items.length === 0) {
cy.log('No notifications — skipping');
return;
}
notification.getKebabMenu().click();
cy.contains('.cds--overflow-menu-options__btn', 'Mark all as read').click();
notification.getNotificationsPageListItems().each(($item) => {
cy.wrap($item).should('not.have.class', 'notifications-page__item--unread');
});
});
});
it('should show unread indicator on bell icon when unread notifications exist', () => {
cy.visit('#/');
cy.wait(5000);
cy.get('[data-testid="header-notification-icon"]').then(($icon) => {
const iconType = $icon.find('cd-icon').attr('type');
cy.log(`Bell icon type: ${iconType}`);
expect(['notification', 'notificationNew']).to.include(iconType);
});
});
});

View File

@ -1,6 +1,10 @@
import { PageHelper } from '../page-helper.po';
export class NotificationSidebarPageHelper extends PageHelper {
pages = {
index: { url: '#/notifications', id: 'cd-notifications-page' }
};
getNotificationIcon() {
return cy.get(`[data-testid='header-notification-icon']`);
}
@ -31,6 +35,10 @@ export class NotificationSidebarPageHelper extends PageHelper {
return cy.get('cd-notification-panel .notification-header__dismiss-btn');
}
getViewAllBtn() {
return cy.get('.notification-footer__view-all-button');
}
open() {
this.getNotificationIcon().click({ force: true });
this.getPanel().should('exist');
@ -48,4 +56,56 @@ export class NotificationSidebarPageHelper extends PageHelper {
this.clearNotifications();
});
}
// Notifications page helpers
getNotificationsPage() {
return cy.get('.notifications-page');
}
getNotificationsPageList() {
return cy.get('.notifications-page__list');
}
getNotificationsPageListItems() {
return cy.get('.notifications-page__item');
}
getNotificationsPageDetail() {
return cy.get('.notifications-page__detail');
}
getKebabMenu() {
return cy.get('.notifications-page__header-menu cds-overflow-menu');
}
getKebabMenuItems() {
return cy.get('.cds--overflow-menu-options__btn');
}
getOccurrencesLabel() {
return cy.get('.cd-notification-item__occurrences');
}
getToastContainer() {
return cy.get('.cds--toast-notification-container');
}
getToasts() {
return cy.get('cds-toast');
}
getToastViewMoreLink() {
return cy.get('.toast-view-more');
}
getToastDuplicateCount() {
return cy.get('.toast-duplicate-count');
}
navigateToNotificationsPage() {
this.open();
this.getViewAllBtn().click({ force: true });
this.getNotificationsPage().should('exist');
}
}

View File

@ -61,6 +61,7 @@
[timestamp]="notification.timestamp"
[message]="notification.message"
[notificationId]="notification.id"
[occurrences]="notification.occurrences"
>
</cd-notification-item>
</div>

View File

@ -59,6 +59,18 @@ cd-notification-area {
&:last-child {
padding-bottom: var(--cds-spacing-05);
}
> .border-subtle-bottom {
margin: 0 calc(-1 * var(--cds-spacing-05));
}
&:has(.notification-item--clickable) {
cursor: pointer;
&:hover {
background-color: var(--cds-layer-hover);
}
}
}
.notification-item {

View File

@ -36,6 +36,14 @@
</p>
}
}
@if (occurrences > 1) {
<span
class="cd-notification-item__occurrences cds--type-label-01"
i18n
>
Occurrences: {{ occurrences }}
</span>
}
</div>
<!-- RIGHT SECTION -->
<div class="cd-notification-item__right">

View File

@ -83,6 +83,11 @@ cd-notification-item {
height: 1rem;
}
.cd-notification-item__occurrences {
color: var(--cds-text-secondary);
margin: 0;
}
.cd-notification-item__chevron svg {
fill: var(--cds-icon-secondary);
}

View File

@ -9,7 +9,6 @@ import {
} 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: `

View File

@ -1,4 +1,11 @@
import { Component, EventEmitter, Input, Output, ViewEncapsulation } from '@angular/core';
import {
Component,
EventEmitter,
Input,
Output,
ViewEncapsulation,
ChangeDetectionStrategy
} from '@angular/core';
import {
NotificationApplication,
NotificationType
@ -10,6 +17,7 @@ import { NotificationService } from '~/app/shared/services/notification.service'
templateUrl: './notification-item.component.html',
styleUrls: ['./notification-item.component.scss'],
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false
})
export class NotificationItemComponent {
@ -29,6 +37,8 @@ export class NotificationItemComponent {
@Input() showChevron: boolean = false;
/* Replace severity icon with unread dot */
@Input() showUnread: boolean = false;
/* Number of deduplicated occurrences */
@Input() occurrences: number = 1;
/* Emitted after notification is deleted via the service */
@Output() deleted = new EventEmitter<string>();

View File

@ -74,6 +74,7 @@
[timestamp]="notification.timestamp"
[message]="notification.displayPreview"
[notificationId]="notification.id"
[occurrences]="notification.occurrences"
[showChevron]="true"
[showUnread]="!readMap()[notification.id]"
>
@ -92,6 +93,12 @@
>
No notifications available
</p>
<p
class="cds-mt-3 cds--type-helper-text-01"
i18n
>
Notifications are stored locally and cleared on browser reset.
</p>
</div>
}
</div>
@ -110,7 +117,10 @@
</div>
<div class="notifications-page__detail-body cds-mt-5">
@if (selected.occurrences > 1) {
<p class="notifications-page__occurrences cds--type-label-01" i18n>
<p
class="notifications-page__occurrences cds--type-label-01"
i18n
>
Occurrences: {{ selected.occurrences }}
</p>
}

View File

@ -22,6 +22,7 @@ describe('NotificationsPageComponent', () => {
let readMapSubject: BehaviorSubject<Record<string, boolean>>;
let notificationService: any;
let mockLocation: any;
let queryParamsSubject: BehaviorSubject<any>;
const createMockNotificationService = () => {
dataSourceSubject = new BehaviorSubject<CdNotification[]>([]);
@ -50,7 +51,8 @@ describe('NotificationsPageComponent', () => {
readMapSubject.next(updated);
localStorage.setItem('cdNotificationsRead', JSON.stringify(updated));
}
})
}),
getNotificationsSnapshot: () => dataSourceSubject.getValue()
};
};
@ -118,6 +120,7 @@ describe('NotificationsPageComponent', () => {
mockLocation = { back: jasmine.createSpy('back') };
const mockNotificationService = createMockNotificationService();
notificationService = mockNotificationService;
queryParamsSubject = new BehaviorSubject<any>({});
localStorage.removeItem('cdNotificationsRead');
@ -130,7 +133,13 @@ describe('NotificationsPageComponent', () => {
{ provide: PrometheusNotificationService, useValue: mockPrometheusNotificationService },
{ provide: AuthStorageService, useValue: mockAuthStorageService },
{ provide: Location, useValue: mockLocation },
{ provide: ActivatedRoute, useValue: { snapshot: { queryParams: {} } } }
{
provide: ActivatedRoute,
useValue: {
snapshot: { queryParams: {} },
queryParams: queryParamsSubject.asObservable()
}
}
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
}).compileComponents();
@ -310,13 +319,8 @@ describe('NotificationsPageComponent', () => {
});
describe('query param pre-selection', () => {
it('should pre-select notification from id query param', async () => {
const route = TestBed.inject(ActivatedRoute);
(route.snapshot.queryParams as any) = { id: '2' };
fixture = TestBed.createComponent(NotificationsPageComponent);
component = fixture.componentInstance;
dataSourceSubject.next(mockNotifications);
it('should pre-select notification from id query param', () => {
queryParamsSubject.next({ id: '2' });
fixture.detectChanges();
expect(component.selectedNotificationID()).toBe('2');
@ -324,25 +328,16 @@ describe('NotificationsPageComponent', () => {
});
it('should not pre-select if id does not match any notification', () => {
const route = TestBed.inject(ActivatedRoute);
(route.snapshot.queryParams as any) = { id: 'nonexistent' };
fixture = TestBed.createComponent(NotificationsPageComponent);
component = fixture.componentInstance;
dataSourceSubject.next(mockNotifications);
queryParamsSubject.next({ id: 'nonexistent' });
fixture.detectChanges();
expect(component.selectedNotificationID()).toBeNull();
});
it('should not override manual selection on subsequent data emissions', () => {
const route = TestBed.inject(ActivatedRoute);
(route.snapshot.queryParams as any) = { id: '2' };
fixture = TestBed.createComponent(NotificationsPageComponent);
component = fixture.componentInstance;
dataSourceSubject.next(mockNotifications);
queryParamsSubject.next({ id: '2' });
fixture.detectChanges();
expect(component.selectedNotificationID()).toBe('2');
component.onNotificationSelect(component.notifications()[0]);
expect(component.selectedNotificationID()).toBe('1');
@ -351,6 +346,17 @@ describe('NotificationsPageComponent', () => {
fixture.detectChanges();
expect(component.selectedNotificationID()).toBe('1');
});
it('should pre-select when navigating from toast view more link', () => {
dataSourceSubject.next(mockNotifications);
fixture.detectChanges();
queryParamsSubject.next({ id: '3' });
fixture.detectChanges();
expect(component.selectedNotificationID()).toBe('3');
expect(notificationService.markAsRead).toHaveBeenCalledWith('3');
});
});
it('should set up interval for Prometheus alerts when permissions exist', () => {

View File

@ -48,6 +48,7 @@ export class NotificationsPageComponent implements OnInit, OnDestroy {
private sub: Subscription;
private interval: number;
private _pendingId: string | null = null;
constructor(
private notificationService: NotificationService,
@ -81,15 +82,15 @@ export class NotificationsPageComponent implements OnInit, OnDestroy {
)
);
const id = this.route.snapshot.queryParams['id'];
if (id && !this.selectedNotificationID()) {
const match = notifications.find((n) => n.id === id);
if (match) {
this.selectedNotificationID.set(id);
this.notificationService.markAsRead(id);
}
}
this._tryPreselect(notifications);
});
this.sub.add(
this.route.queryParams.subscribe((params) => {
this._pendingId = params['id'] || null;
this._tryPreselect(this.notificationService.getNotificationsSnapshot());
})
);
}
ngOnDestroy(): void {
@ -132,6 +133,16 @@ export class NotificationsPageComponent implements OnInit, OnDestroy {
}
}
private _tryPreselect(notifications: CdNotification[]): void {
if (!this._pendingId || this.selectedNotificationID()) return;
const match = notifications.find((n) => n.id === this._pendingId);
if (match) {
this.selectedNotificationID.set(this._pendingId);
this.notificationService.markAsRead(this._pendingId);
this._pendingId = null;
}
}
private triggerPrometheusAlerts(): void {
this.prometheusAlertService.refresh();
this.prometheusNotificationService.refresh();

View File

@ -1,10 +1,10 @@
@if (isMuted) {
@if (isMuted()) {
<cd-icon
type="notificationOff"
[size]="iconSize"
>
</cd-icon>
} @else if (!isMuted && (hasRunningTasks || hasNotifications)) {
} @else if (hasRunningTasks() || hasNotifications()) {
<cd-icon
type="notificationNew"
[size]="iconSize"

View File

@ -1,44 +1,41 @@
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { BehaviorSubject } from 'rxjs';
import { CdNotification, CdNotificationConfig } from '~/app/shared/models/cd-notification';
import { ExecutingTask } from '~/app/shared/models/executing-task';
import { NotificationService } from '~/app/shared/services/notification.service';
import { SummaryService } from '~/app/shared/services/summary.service';
import { SharedModule } from '~/app/shared/shared.module';
import { configureTestBed } from '~/testing/unit-test-helper';
import { NotificationsComponent } from './notifications.component';
import { BehaviorSubject } from 'rxjs';
describe('NotificationsComponent', () => {
let component: NotificationsComponent;
let fixture: ComponentFixture<NotificationsComponent>;
let summaryService: SummaryService;
let notificationService: NotificationService;
const hasUnreadSource = new BehaviorSubject(false);
const muteSource = new BehaviorSubject(false);
const notificationServiceMock = {
dataSource: new BehaviorSubject([]),
hasUnreadSource,
get hasUnread$() {
return hasUnreadSource.asObservable();
},
muteState$: new BehaviorSubject(false)
hasUnread$: hasUnreadSource.asObservable(),
muteState$: muteSource.asObservable()
};
configureTestBed({
imports: [HttpClientTestingModule, SharedModule, RouterTestingModule],
imports: [HttpClientTestingModule],
declarations: [NotificationsComponent],
providers: [{ provide: NotificationService, useValue: notificationServiceMock }]
providers: [{ provide: NotificationService, useValue: notificationServiceMock }],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
});
beforeEach(() => {
hasUnreadSource.next(false);
muteSource.next(false);
fixture = TestBed.createComponent(NotificationsComponent);
component = fixture.componentInstance;
summaryService = TestBed.inject(SummaryService);
notificationService = TestBed.inject(NotificationService);
fixture.detectChanges();
});
@ -46,24 +43,38 @@ describe('NotificationsComponent', () => {
expect(component).toBeTruthy();
});
it('should subscribe and check if there are running tasks', () => {
expect(component.hasRunningTasks).toBeFalsy();
it('should reflect running tasks from summary service', () => {
expect(component.hasRunningTasks()).toBe(false);
const task = new ExecutingTask('task', { name: 'name' });
summaryService['summaryDataSource'].next({ executing_tasks: [task] });
expect(component.hasRunningTasks).toBeTruthy();
expect(component.hasRunningTasks()).toBe(true);
});
it('should show notificationNew icon if there are notifications', () => {
const notification = new CdNotification(new CdNotificationConfig());
notificationService['dataSource'].next([notification]);
notificationService['hasUnreadSource'].next(true);
it('should show notificationNew icon when unread notifications exist', () => {
hasUnreadSource.next(true);
fixture.detectChanges();
const icon = fixture.debugElement.nativeElement.querySelector('cd-icon');
expect(icon).toBeTruthy();
expect(icon.getAttribute('type')).toBe('notificationNew');
});
it('should show notification icon when no unread and no running tasks', () => {
fixture.detectChanges();
const icon = fixture.debugElement.nativeElement.querySelector('cd-icon');
expect(icon).toBeTruthy();
expect(icon.getAttribute('type')).toBe('notification');
});
it('should show notificationOff icon when muted', () => {
muteSource.next(true);
fixture.detectChanges();
const icon = fixture.debugElement.nativeElement.querySelector('cd-icon');
expect(icon).toBeTruthy();
expect(icon.getAttribute('type')).toBe('notificationOff');
});
});

View File

@ -1,6 +1,6 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { Subscription } from 'rxjs';
import { Component, ChangeDetectionStrategy, inject, Signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs/operators';
import { ICON_TYPE, IconSize } from '~/app/shared/enum/icons.enum';
import { NotificationService } from '~/app/shared/services/notification.service';
@ -10,42 +10,21 @@ import { SummaryService } from '~/app/shared/services/summary.service';
selector: 'cd-notifications',
templateUrl: './notifications.component.html',
styleUrls: ['./notifications.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false
})
export class NotificationsComponent implements OnInit, OnDestroy {
export class NotificationsComponent {
icons = ICON_TYPE;
iconSize = IconSize.size20;
hasRunningTasks = false;
hasNotifications = false;
isMuted = false;
private subs = new Subscription();
constructor(
public notificationService: NotificationService,
private summaryService: SummaryService
) {}
private notificationService = inject(NotificationService);
private summaryService = inject(SummaryService);
ngOnInit() {
this.subs.add(
this.summaryService.subscribe((summary) => {
this.hasRunningTasks = summary.executing_tasks.length > 0;
})
);
hasRunningTasks: Signal<boolean> = toSignal(
this.summaryService.summaryData$.pipe(map((summary) => summary?.executing_tasks?.length > 0)),
{ initialValue: false }
);
this.subs.add(
this.notificationService.muteState$.subscribe((isMuted) => {
this.isMuted = isMuted;
})
);
this.subs.add(
this.notificationService.hasUnread$.subscribe(
(hasUnread) => (this.hasNotifications = hasUnread)
)
);
}
ngOnDestroy(): void {
this.subs.unsubscribe();
}
isMuted = toSignal(this.notificationService.muteState$, { initialValue: false });
hasNotifications = toSignal(this.notificationService.hasUnread$, { initialValue: false });
}

View File

@ -3,7 +3,8 @@ import {
OnInit,
AfterViewChecked,
HostListener,
ElementRef
ElementRef,
ChangeDetectionStrategy
} from '@angular/core';
import { animate, style, transition, trigger } from '@angular/animations';
import { Router } from '@angular/router';
@ -15,6 +16,7 @@ import { NotificationService } from '../../services/notification.service';
selector: 'cd-toast',
templateUrl: './notification-toast.component.html',
styleUrls: ['./notification-toast.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [
trigger('toastAnimation', [
transition(
@ -39,6 +41,7 @@ import { NotificationService } from '../../services/notification.service';
})
export class ToastComponent implements OnInit, AfterViewChecked {
activeToasts$: Observable<ToastContent[]>;
private toastsDirty = false;
constructor(
private notificationService: NotificationService,
@ -48,15 +51,22 @@ export class ToastComponent implements OnInit, AfterViewChecked {
ngOnInit() {
this.activeToasts$ = this.notificationService.activeToasts$;
this.notificationService.activeToasts$.subscribe(() => {
this.toastsDirty = true;
});
}
ngAfterViewChecked() {
if (!this.toastsDirty) return;
this.toastsDirty = false;
const toasts = this.el.nativeElement.querySelectorAll('cds-toast');
toasts.forEach((toast: HTMLElement) => {
const subtitle = toast.querySelector('.cds--toast-notification__subtitle');
const viewMore = toast.querySelector('.toast-view-more') as HTMLElement;
if (!subtitle || !viewMore) return;
const isTruncated = subtitle.scrollHeight > subtitle.clientHeight;
const textEl = subtitle.querySelector('.toast-message') || subtitle;
const isTruncated = textEl.scrollHeight > textEl.clientHeight;
viewMore.style.display = isTruncated ? '' : 'none';
});
}

View File

@ -5,7 +5,7 @@ import _ from 'lodash';
import { configureTestBed } from '~/testing/unit-test-helper';
import { RbdService } from '../api/rbd.service';
import { NotificationType } from '../enum/notification-type.enum';
import { CdNotificationConfig } from '../models/cd-notification';
import { CdNotification, CdNotificationConfig } from '../models/cd-notification';
import { FinishedTask } from '../models/finished-task';
import { CdDatePipe } from '../pipes/cd-date.pipe';
import { NotificationService } from './notification.service';
@ -157,15 +157,25 @@ describe('NotificationService', () => {
flush();
}));
it('combines different notifications with the same title', fakeAsync(() => {
it('deduplicates notifications with the same title, type, and message', fakeAsync(() => {
service.show(NotificationType.error, '502 - Bad Gateway', 'Error occurred in path a');
tick(60);
tick(600);
service.show(NotificationType.error, '502 - Bad Gateway', 'Error occurred in path a');
tick(600);
const notifications = service['dataSource'].getValue();
expect(notifications.length).toBe(1);
expect(notifications[0].title).toBe('502 - Bad Gateway');
expect(notifications[0].occurrences).toBe(2);
flush();
}));
it('does not deduplicate notifications with different messages', fakeAsync(() => {
service.show(NotificationType.error, '502 - Bad Gateway', 'Error occurred in path a');
tick(600);
service.show(NotificationType.error, '502 - Bad Gateway', 'Error occurred in path b');
expectSavedNotificationToHave({
type: NotificationType.error,
title: '502 - Bad Gateway',
message: 'Error occurred in path a (+1 more)'
});
tick(600);
const notifications = service['dataSource'].getValue();
expect(notifications.length).toBe(2);
flush();
}));
@ -286,4 +296,65 @@ describe('NotificationService', () => {
flush();
}));
});
describe('Active toast limit', () => {
it('should show only the latest 2 toasts', fakeAsync(() => {
for (let i = 0; i < 5; i++) {
service.show(NotificationType.error, `Error ${i}`, `Message ${i}`);
tick(600);
}
const toasts = service['activeToastsSource'].getValue();
expect(toasts.length).toBe(2);
expect(toasts[0].title).toBe('Error 4');
expect(toasts[1].title).toBe('Error 3');
flush();
}));
});
describe('Read state', () => {
it('should mark a notification as read', () => {
const config = new CdNotificationConfig(NotificationType.info, 'Test');
const notification = new CdNotification(config);
service.save(notification);
expect(service['hasUnreadSource'].getValue()).toBe(true);
service.markAsRead(notification.id);
expect(service['hasUnreadSource'].getValue()).toBe(false);
});
it('should mark all notifications as read', () => {
const n1 = new CdNotification(new CdNotificationConfig(NotificationType.info, 'A'));
const n2 = new CdNotification(new CdNotificationConfig(NotificationType.error, 'B'));
service.save(n1);
service.save(n2);
expect(service['hasUnreadSource'].getValue()).toBe(true);
service.markAllAsRead();
expect(service['hasUnreadSource'].getValue()).toBe(false);
});
it('should clear read state when a notification gets a new occurrence', () => {
const n1 = new CdNotification(new CdNotificationConfig(NotificationType.error, 'Err', 'msg'));
service.save(n1);
service.markAsRead(n1.id);
expect(service['hasUnreadSource'].getValue()).toBe(false);
const n2 = new CdNotification(new CdNotificationConfig(NotificationType.error, 'Err', 'msg'));
service.save(n2);
expect(service['hasUnreadSource'].getValue()).toBe(true);
expect(service['dataSource'].getValue()[0].occurrences).toBe(2);
});
it('should remove by id', () => {
const n1 = new CdNotification(new CdNotificationConfig(NotificationType.info, 'A'));
service.save(n1);
expect(service['dataSource'].getValue().length).toBe(1);
expect(service.removeById(n1.id)).toBe(true);
expect(service['dataSource'].getValue().length).toBe(0);
});
it('should return false when removing non-existent id', () => {
expect(service.removeById('nonexistent')).toBe(false);
});
});
});

View File

@ -13,6 +13,18 @@ import { FinishedTask } from '../models/finished-task';
import { CdDatePipe } from '../pipes/cd-date.pipe';
import { TaskMessageService } from './task-message.service';
// Carbon's cds-toast renders title, subtitle, and caption via [innerHTML].
// Angular's DomSanitizer only partially protects against XSS in innerHTML bindings,
// so we escape untrusted input at the source before passing it to Carbon.
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
@Injectable({
providedIn: 'root'
})
@ -24,6 +36,7 @@ export class NotificationService {
[NotificationType.warning]: 'warning'
};
private readonly MAX_NOTIFICATIONS = 10;
private readonly MAX_ACTIVE_TOASTS = 2;
private readonly SHOW_DELAY = 10;
private readonly QUEUE_DELAY = 500;
private readonly ERROR_TOAST_DURATION = 10000;
@ -66,9 +79,21 @@ export class NotificationService {
let notifications: CdNotification[] = [];
if (_.isString(stringNotifications)) {
try {
const ALLOWED_KEYS = [
'id',
'title',
'message',
'type',
'timestamp',
'application',
'prometheusAlert',
'isFinishedTask',
'alertSilenced',
'silenceId'
];
notifications = JSON.parse(stringNotifications, (_key, value) => {
if (_.isPlainObject(value)) {
return _.assign(new CdNotification(), value);
return _.assign(new CdNotification(), _.pick(value, ALLOWED_KEYS));
}
return value;
});
@ -90,7 +115,17 @@ export class NotificationService {
}
private _persistReadMap(readMap: Record<string, boolean>) {
localStorage.setItem(this.LOCAL_STORAGE_READ_KEY, JSON.stringify(readMap));
const ids = new Set(this.dataSource.getValue().map((n) => n.id));
const pruned: Record<string, boolean> = {};
for (const id of Object.keys(readMap)) {
if (ids.has(id)) pruned[id] = true;
}
try {
localStorage.setItem(this.LOCAL_STORAGE_READ_KEY, JSON.stringify(pruned));
} catch {
// quota exceeded — clear stale read state
localStorage.removeItem(this.LOCAL_STORAGE_READ_KEY);
}
}
private _recomputeHasUnread(notifications: CdNotification[]) {
@ -130,18 +165,30 @@ export class NotificationService {
/**
* Saving a shown notification in local storage
*/
save(notification: CdNotification) {
save(notification: CdNotification): string {
const current = this.dataSource.getValue();
const existing = current.find(
(n) => n.title === notification.title && n.type === notification.type
(n) =>
n.title === notification.title &&
n.type === notification.type &&
n.message === notification.message
);
let notifications: CdNotification[];
let storedId: string;
if (existing) {
existing.occurrences = (existing.occurrences || 1) + 1;
existing.timestamp = notification.timestamp;
existing.message = notification.message;
storedId = existing.id;
notifications = [...current];
const readMap = { ...this.readMapSource.getValue() };
delete readMap[existing.id];
this.readMapSource.next(readMap);
this._persistReadMap(readMap);
} else {
storedId = notification.id;
notifications = [notification, ...current];
}
@ -152,6 +199,7 @@ export class NotificationService {
this.dataSource.next(limited);
this._recomputeHasUnread(limited);
this._persistNotifications(limited);
return storedId;
}
/**
@ -240,42 +288,20 @@ export class NotificationService {
}
private _showQueued() {
this._getUnifiedTitleQueue().forEach((config) => {
this.queued.forEach((config) => {
const notification = new CdNotification(config);
if (!notification.isFinishedTask) {
this.save(notification);
const storedId = this.save(notification);
notification.id = storedId;
}
this._showToasty(notification);
});
this.queued = [];
}
private _getUnifiedTitleQueue(): CdNotificationConfig[] {
return Object.values(this._queueShiftByTitle()).map((configs) => {
const config = configs[0];
if (configs.length > 1) {
config.message = '<ul>' + configs.map((c) => `<li>${c.message}</li>`).join('') + '</ul>';
}
return config;
});
}
private _queueShiftByTitle(): { [key: string]: CdNotificationConfig[] } {
const byTitle: { [key: string]: CdNotificationConfig[] } = {};
let config: CdNotificationConfig;
while ((config = this.queued.shift())) {
if (!byTitle[config.title]) {
byTitle[config.title] = [];
}
byTitle[config.title].push(config);
}
return byTitle;
}
private _showToasty(notification: CdNotification) {
// Exit immediately if no toasty should be displayed.
if (this.hideToasties) {
if (this.hideToasties || this.panelState.value) {
return;
}
@ -283,8 +309,11 @@ export class NotificationService {
const carbonType = this.NOTIFICATION_TYPE_MAP[notification.type] || 'info';
const lowContrast = notification.options?.lowContrast || false;
const escapedTitle = escapeHtml(notification.title || '');
const escapedMessage = escapeHtml(notification.message || '');
const existing = this.activeToasts.find(
(t) => t.title === notification.title && t.type === carbonType
(t) =>
t.title === escapedTitle && t.type === carbonType && t.originalSubtitle === escapedMessage
);
if (existing) {
existing.duplicateCount = (existing.duplicateCount || 1) + 1;
@ -295,10 +324,9 @@ export class NotificationService {
return;
}
const subtitle = notification.message || '';
const toast: ToastContent = {
title: notification.title,
subtitle,
title: escapedTitle,
subtitle: escapedMessage,
caption: this._renderTimeAndApplicationHtml(notification),
type: carbonType,
lowContrast: lowContrast,
@ -310,10 +338,13 @@ export class NotificationService {
: this.DEFAULT_TOAST_DURATION),
notificationId: notification.id,
duplicateCount: 1,
originalSubtitle: subtitle
originalSubtitle: escapedMessage
};
this.activeToasts.unshift(toast);
while (this.activeToasts.length > this.MAX_ACTIVE_TOASTS) {
this.activeToasts.pop();
}
this.activeToastsSource.next(this.activeToasts);
if (toast.duration && toast.duration > 0) {
@ -328,9 +359,11 @@ export class NotificationService {
}
private _renderTimeAndApplicationHtml(notification: CdNotification): string {
const date = escapeHtml(this.cdDatePipe.transform(notification.timestamp) || '');
const id = encodeURIComponent(notification.id);
return `<div class="toast-caption-container">
<small class="date">${this.cdDatePipe.transform(notification.timestamp)}</small>
<a class="toast-view-more cds--type-label-01" href="#/notifications?id=${notification.id}" i18n>View more</a>
<small class="date">${date}</small>
<a class="toast-view-more cds--type-label-01" href="#/notifications?id=${id}" i18n>View more</a>
</div>`;
}
@ -350,7 +383,7 @@ export class NotificationService {
}
removeToast(toast: ToastContent) {
this.activeToasts = this.activeToasts.filter((t) => !_.isEqual(t, toast));
this.activeToasts = this.activeToasts.filter((t) => t.notificationId !== toast.notificationId);
this.activeToastsSource.next(this.activeToasts);
}
@ -404,10 +437,12 @@ export class NotificationService {
*/
togglePanel(isOpen: boolean) {
this.panelState.next(isOpen);
if (isOpen) this.clearAllToasts();
}
setPanelState(isOpen: boolean) {
this.panelState.next(isOpen);
if (isOpen) this.clearAllToasts();
}
getPanelState(): boolean {