>
<div class="notification-wrapper">
<div
- class="notification-item"
+ class="notification-item notification-item--clickable"
data-testid="notification-item"
+ role="button"
+ tabindex="0"
+ (click)="navigateToNotification(notification)"
+ (keydown.enter)="navigateToNotification(notification)"
>
<cd-notification-item
[type]="notification.type"
min-block-size: 0;
}
-:host {
- flex: 1;
- overflow-y: auto;
- overflow-x: hidden;
- min-block-size: 0;
-}
-
.notification-section-heading {
margin: 0;
color: var(--cds-text-primary);
color: var(--cds-text-primary);
line-height: 1.25;
display: block;
-<<<<<<< HEAD
overflow-wrap: break-word;
-=======
- word-break: 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;
- word-break: break-word;
->>>>>>> 8e9c61bcc14 (mgr/dashboard: Fix the footer - view all button css in notification panel)
}
.notification-empty {
mockDataSource = new BehaviorSubject<CdNotification[]>(mockNotifications);
const spy = {
remove: jasmine.createSpy('remove'),
+ removeById: jasmine.createSpy('removeById').and.returnValue(true),
dataSource: mockDataSource,
data$: mockDataSource.asObservable(),
getNotificationsSnapshot: () => mockDataSource.getValue()
import { ExecutingTask } from '~/app/shared/models/executing-task';
import { TaskMessageService } from '~/app/shared/services/task-message.service';
import { Icons } from '~/app/shared/enum/icons.enum';
+import { Router } from '@angular/router';
@Component({
selector: 'cd-notification-area',
constructor(
private notificationService: NotificationService,
private summaryService: SummaryService,
- private taskMessageService: TaskMessageService
+ private taskMessageService: TaskMessageService,
+ private router: Router
) {}
ngOnInit(): void {
ngOnDestroy(): void {
this.subs.unsubscribe();
}
+
+ navigateToNotification(notification: CdNotification) {
+ this.notificationService.togglePanel(false);
+ this.router.navigate(['/notifications'], {
+ queryParams: { id: notification.id }
+ });
+ }
+
+ removeNotification(notification: CdNotification, event: MouseEvent) {
+ event.stopPropagation();
+ event.preventDefault();
+ this.notificationService.removeById(notification.id);
+ }
}
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');
+ it('should call notificationService.removeById and emit deleted on delete', () => {
+ spyOn(notificationService, 'removeById').and.returnValue(true);
const deleteBtn = fixture.nativeElement.querySelector('.cd-notification-item__delete');
deleteBtn.click();
- expect(notificationService.remove).toHaveBeenCalledWith(0);
+ expect(notificationService.removeById).toHaveBeenCalledWith('test-1');
expect(hostComponent.deletedId).toBe('test-1');
});
});
it('should not emit deleted when notification is not found', () => {
- spyOn(notificationService, 'getNotificationsSnapshot').and.returnValue([]);
- spyOn(notificationService, 'remove');
+ spyOn(notificationService, 'removeById').and.returnValue(false);
const deleteBtn = fixture.nativeElement.querySelector('.cd-notification-item__delete');
deleteBtn.click();
- expect(notificationService.remove).not.toHaveBeenCalled();
+ expect(notificationService.removeById).toHaveBeenCalledWith('test-1');
expect(hostComponent.deletedId).toBeNull();
});
});
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);
+ if (this.notificationService.removeById(this.notificationId)) {
this.deleted.emit(this.notificationId);
}
}
>
Notifications
</h4>
- <a
- cdsLink
- class="notifications-page__header-action"
- tabindex="0"
- (click)="clearAll()"
- (keydown.enter)="clearAll()"
- i18n
+ <cds-overflow-menu
+ class="notifications-page__header-menu"
+ [flip]="true"
+ description=""
+ i18n-description
>
- Clear all
- </a>
+ <li
+ class="cds--overflow-menu-options__option"
+ [class.cds--overflow-menu-options__option--disabled]="allRead() || hasNoNotifications()"
+ >
+ <button
+ class="cds--overflow-menu-options__btn"
+ type="button"
+ [disabled]="allRead() || hasNoNotifications()"
+ (click)="markAllAsRead()"
+ i18n
+ >
+ Mark all as read
+ </button>
+ </li>
+ <li
+ class="cds--overflow-menu-options__option"
+ [class.cds--overflow-menu-options__option--disabled]="hasNoNotifications()"
+ >
+ <button
+ class="cds--overflow-menu-options__btn"
+ type="button"
+ [disabled]="hasNoNotifications()"
+ (click)="clearAll()"
+ i18n
+ >
+ Clear all
+ </button>
+ </li>
+ </cds-overflow-menu>
</header>
<div class="notifications-page__body">
></cd-notification-item>
</div>
<div class="notifications-page__detail-body cds-mt-5">
+ @if (selected.occurrences > 1) {
+ <p class="notifications-page__occurrences cds--type-label-01" i18n>
+ Occurrences: {{ selected.occurrences }}
+ </p>
+ }
<p class="notifications-page__detail-text cds--type-body-01">
{{ selected.displayPreview }}
</p>
display: inline-flex;
align-items: center;
gap: var(--cds-spacing-02);
+ }
- &:last-child {
- justify-self: end;
- }
+ &__header-menu {
+ justify-self: end;
}
&__header-title {
}
}
+ &__occurrences {
+ margin: 0 0 var(--cds-spacing-03) 0;
+ color: var(--cds-text-secondary);
+ }
+
&__detail-text {
margin: 0;
color: var(--cds-text-primary);
import { PrometheusNotificationService } from '~/app/shared/services/prometheus-notification.service';
import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
+import { ActivatedRoute } from '@angular/router';
import { SharedModule } from '~/app/shared/shared.module';
describe('NotificationsPageComponent', () => {
next: (value: CdNotification[]) => dataSourceSubject.next(value)
},
remove: jasmine.createSpy('remove'),
+ removeById: jasmine.createSpy('removeById').and.returnValue(true),
removeAll: jasmine.createSpy('removeAll'),
+ markAllAsRead: jasmine.createSpy('markAllAsRead').and.callFake(() => {
+ const notifications = dataSourceSubject.getValue();
+ const updated = { ...readMapSubject.getValue() };
+ notifications.forEach((n) => (updated[n.id] = true));
+ readMapSubject.next(updated);
+ localStorage.setItem('cdNotificationsRead', JSON.stringify(updated));
+ }),
markAsRead: jasmine.createSpy('markAsRead').and.callFake((id: string) => {
const current = readMapSubject.getValue();
if (!current[id]) {
{ provide: PrometheusAlertService, useValue: mockPrometheusAlertService },
{ provide: PrometheusNotificationService, useValue: mockPrometheusNotificationService },
{ provide: AuthStorageService, useValue: mockAuthStorageService },
- { provide: Location, useValue: mockLocation }
+ { provide: Location, useValue: mockLocation },
+ { provide: ActivatedRoute, useValue: { snapshot: { queryParams: {} } } }
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
}).compileComponents();
} as any;
component.removeNotification(component.notifications()[0], mockEvent);
expect(mockEvent.stopPropagation).toHaveBeenCalled();
- expect(notificationService.remove).toHaveBeenCalledWith(0);
+ expect(notificationService.removeById).toHaveBeenCalledWith('1');
});
it('should clear selection if removed notification was selected', () => {
expect(component.readMap()['2']).toBe(true);
expect(component.readMap()['1']).toBeFalsy();
});
+
+ it('should mark all notifications as read', () => {
+ component.markAllAsRead();
+ fixture.detectChanges();
+ expect(notificationService.markAllAsRead).toHaveBeenCalled();
+ expect(component.readMap()['1']).toBe(true);
+ expect(component.readMap()['2']).toBe(true);
+ expect(component.readMap()['3']).toBe(true);
+ });
});
describe('displayTitle and displayPreview', () => {
});
});
+ 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);
+ fixture.detectChanges();
+
+ expect(component.selectedNotificationID()).toBe('2');
+ expect(notificationService.markAsRead).toHaveBeenCalledWith('2');
+ });
+
+ 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);
+ 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);
+ fixture.detectChanges();
+
+ component.onNotificationSelect(component.notifications()[0]);
+ expect(component.selectedNotificationID()).toBe('1');
+
+ dataSourceSubject.next(mockNotifications);
+ fixture.detectChanges();
+ expect(component.selectedNotificationID()).toBe('1');
+ });
+ });
+
it('should set up interval for Prometheus alerts when permissions exist', () => {
mockAuthStorageService.getPermissions.and.returnValue({
prometheus: { read: true },
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { Location } from '@angular/common';
+import { ActivatedRoute } from '@angular/router';
import { Subscription } from 'rxjs';
import { NotificationService } from '~/app/shared/services/notification.service';
import { CdNotification } from '~/app/shared/models/cd-notification';
this.notifications().find((n) => n.id === this.selectedNotificationID())
);
+ hasNoNotifications = computed(() => this.notifications().length === 0);
+
+ allRead = computed(() => {
+ const map = this.readMap();
+ return this.notifications().every((n) => map[n.id]);
+ });
+
private sub: Subscription;
private interval: number;
private prometheusAlertService: PrometheusAlertService,
private prometheusNotificationService: PrometheusNotificationService,
private authStorageService: AuthStorageService,
- private location: Location
+ private location: Location,
+ private route: ActivatedRoute
) {
this.readMap = toSignal(this.notificationService.readMap$, {
initialValue: {} as Record<string, boolean>
})
)
);
+
+ 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.location.back();
}
+ markAllAsRead(): void {
+ this.notificationService.markAllAsRead();
+ }
+
clearAll(): void {
this.notificationService.removeAll();
this.selectedNotificationID.set(null);
removeNotification(notification: DisplayNotification, event: MouseEvent): void {
event.stopPropagation();
- const index = this.notifications().findIndex((n) => n.id === notification.id);
- if (index > -1) {
- this.notificationService.remove(index);
+ if (this.notificationService.removeById(notification.id)) {
if (this.selectedNotificationID() === notification.id) {
this.selectedNotificationID.set(null);
}
@use '@carbon/styles/scss/theme' as *;
@use '@carbon/styles/scss/spacing' as *;
@use '@carbon/styles/scss/layer' as *;
-@use '@carbon/styles/scss/type' as *;
.cds--toast-notification-container {
position: fixed;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
- word-break: break-word;
+ overflow-wrap: break-word;
+
+ .toast-message {
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ }
+
+ .toast-duplicate-count {
+ display: block;
+ color: $text-secondary;
+ margin-top: $spacing-02;
+ }
}
.cds--toast-notification__close-button {
.toast-caption-container .date {
flex-shrink: 0;
}
+
+ .toast-caption-container .toast-view-more {
+ color: $link-primary;
+ text-decoration: none;
+ margin-inline-start: auto;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
}
}
}
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { Router } from '@angular/router';
import { of } from 'rxjs';
import { ToastContent } from 'carbon-components-angular';
let component: ToastComponent;
let fixture: ComponentFixture<ToastComponent>;
let mockToasts: ToastContent[];
+ let mockRouter: any;
const mockNotificationService = {
activeToasts$: of([]),
- removeToast: jest.fn()
+ removeToast: jest.fn(),
+ clearAllToasts: jest.fn()
+ };
+
+ mockRouter = {
+ navigateByUrl: jest.fn()
};
configureTestBed({
{
provide: NotificationService,
useValue: mockNotificationService
+ },
+ {
+ provide: Router,
+ useValue: mockRouter
}
]
});
component.onToastClose(toast);
expect(mockNotificationService.removeToast).toHaveBeenCalledWith(toast);
});
+
+ describe('view more click', () => {
+ it('should navigate and clear toasts when view-more link is clicked', () => {
+ fixture.detectChanges();
+ const link = document.createElement('a');
+ link.classList.add('toast-view-more');
+ link.setAttribute('href', '#/notifications?id=abc123');
+ fixture.nativeElement.appendChild(link);
+
+ const event = new MouseEvent('click', { bubbles: true });
+ const preventDefaultSpy = jest.spyOn(event, 'preventDefault');
+ link.dispatchEvent(event);
+
+ expect(preventDefaultSpy).toHaveBeenCalled();
+ expect(mockRouter.navigateByUrl).toHaveBeenCalledWith('/notifications?id=abc123');
+ expect(mockNotificationService.clearAllToasts).toHaveBeenCalled();
+ });
+
+ it('should not navigate for non view-more clicks', () => {
+ fixture.detectChanges();
+ mockRouter.navigateByUrl.mockClear();
+ mockNotificationService.clearAllToasts.mockClear();
+
+ const span = document.createElement('span');
+ fixture.nativeElement.appendChild(span);
+ span.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+
+ expect(mockRouter.navigateByUrl).not.toHaveBeenCalled();
+ expect(mockNotificationService.clearAllToasts).not.toHaveBeenCalled();
+ });
+ });
});
-import { Component, OnInit } from '@angular/core';
+import {
+ Component,
+ OnInit,
+ AfterViewChecked,
+ HostListener,
+ ElementRef
+} from '@angular/core';
import { animate, style, transition, trigger } from '@angular/animations';
+import { Router } from '@angular/router';
import { Observable } from 'rxjs';
import { ToastContent } from 'carbon-components-angular';
import { NotificationService } from '../../services/notification.service';
],
standalone: false
})
-export class ToastComponent implements OnInit {
+export class ToastComponent implements OnInit, AfterViewChecked {
activeToasts$: Observable<ToastContent[]>;
- constructor(private notificationService: NotificationService) {}
+ constructor(
+ private notificationService: NotificationService,
+ private router: Router,
+ private el: ElementRef
+ ) {}
ngOnInit() {
this.activeToasts$ = this.notificationService.activeToasts$;
}
+ ngAfterViewChecked() {
+ 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;
+ viewMore.style.display = isTruncated ? '' : 'none';
+ });
+ }
+
+ @HostListener('click', ['$event'])
+ onViewMoreClick(event: Event) {
+ const target = event.target as HTMLElement;
+ if (target.classList.contains('toast-view-more')) {
+ event.preventDefault();
+ const href = target.getAttribute('href');
+ if (href) {
+ this.router.navigateByUrl(href.replace('#', ''));
+ }
+ this.notificationService.clearAllToasts();
+ }
+ }
+
onToastClose(toast: ToastContent) {
this.notificationService.removeToast(toast);
}
iconClass: string;
duration: number;
borderClass: string;
+ occurrences = 1;
alertSilenced = false;
silenceId?: string;
private readonly MAX_NOTIFICATIONS = 10;
private readonly SHOW_DELAY = 10;
private readonly QUEUE_DELAY = 500;
+ private readonly ERROR_TOAST_DURATION = 10000;
+ private readonly DEFAULT_TOAST_DURATION = 5000;
private readonly LOCAL_STORAGE_KEY = 'cdNotifications';
private readonly LOCAL_STORAGE_MUTE_KEY = 'cdNotificationsMuted';
private readonly LOCAL_STORAGE_READ_KEY = 'cdNotificationsRead';
* Saving a shown notification in local storage
*/
save(notification: CdNotification) {
- const notifications = [notification, ...this.dataSource.getValue()];
+ const current = this.dataSource.getValue();
+ const existing = current.find(
+ (n) => n.title === notification.title && n.type === notification.type
+ );
+
+ let notifications: CdNotification[];
+ if (existing) {
+ existing.occurrences = (existing.occurrences || 1) + 1;
+ existing.timestamp = notification.timestamp;
+ notifications = [...current];
+ } else {
+ notifications = [notification, ...current];
+ }
const limited = notifications
.sort((a, b) => (a.timestamp > b.timestamp ? -1 : 1))
this._persistNotifications(notifications);
}
+ removeById(id: string): boolean {
+ const notifications = this.dataSource.getValue();
+ const index = notifications.findIndex((n) => n.id === id);
+ if (index === -1) return false;
+ this.remove(index);
+ return true;
+ }
+
/**
* Removes all current saved notifications from storage (and any appearing toasts)
*/
this.readMapSource.next({});
this.dataSource.next([]);
this.hasUnreadSource.next(false);
- this._clearAllToasts();
+ this.clearAllToasts();
}
/**
const carbonType = this.NOTIFICATION_TYPE_MAP[notification.type] || 'info';
const lowContrast = notification.options?.lowContrast || false;
+ const existing = this.activeToasts.find(
+ (t) => t.title === notification.title && t.type === carbonType
+ );
+ if (existing) {
+ existing.duplicateCount = (existing.duplicateCount || 1) + 1;
+ const count = existing.duplicateCount - 1;
+ existing.subtitle = `<span class="toast-message">${existing.originalSubtitle}</span><span class="toast-duplicate-count">(+${count} more)</span>`;
+ existing.caption = this._renderTimeAndApplicationHtml(notification);
+ this.activeToastsSource.next([...this.activeToasts]);
+ return;
+ }
+
+ const subtitle = notification.message || '';
const toast: ToastContent = {
title: notification.title,
- subtitle: notification.message || '',
+ subtitle,
caption: this._renderTimeAndApplicationHtml(notification),
type: carbonType,
lowContrast: lowContrast,
showClose: true,
- duration: notification.options?.timeOut || 5000
+ duration:
+ notification.options?.timeOut ||
+ (notification.type === NotificationType.error
+ ? this.ERROR_TOAST_DURATION
+ : this.DEFAULT_TOAST_DURATION),
+ notificationId: notification.id,
+ duplicateCount: 1,
+ originalSubtitle: subtitle
};
- // Add new toast to the beginning of the array
this.activeToasts.unshift(toast);
this.activeToastsSource.next(this.activeToasts);
- // Handle duration-based auto-dismissal
if (toast.duration && toast.duration > 0) {
this.ngZone.runOutsideAngular(() => {
setTimeout(() => {
}
private _renderTimeAndApplicationHtml(notification: CdNotification): string {
- let html = `<div class="toast-caption-container">
- <small class="date">${this.cdDatePipe.transform(notification.timestamp)}</small>`;
-
- html += '</div>';
- return html;
+ 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>
+ </div>`;
}
- private _clearAllToasts() {
+ clearAllToasts() {
this.activeToasts = [];
this.activeToastsSource.next(this.activeToasts);
}
this._persistReadMap(updated);
this._recomputeHasUnread(this.dataSource.getValue());
}
+
+ markAllAsRead() {
+ const notifications = this.dataSource.getValue();
+ const updated = { ...this.readMapSource.getValue() };
+ notifications.forEach((n) => (updated[n.id] = true));
+ this.readMapSource.next(updated);
+ this._persistReadMap(updated);
+ this._recomputeHasUnread(notifications);
+ }
}