import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
-import { ToastrModule } from 'ngx-toastr';
-
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { CephModule } from './ceph/ceph.module';
imports: [
BrowserModule,
BrowserAnimationsModule,
- ToastrModule.forRoot({
- positionClass: 'toast-top-right',
- preventDuplicates: true,
- enableHtml: true
- }),
AppRoutingModule,
CoreModule,
SharedModule,
<cds-placeholder></cds-placeholder>
</div>
</cd-navigation>
+ <!-- Toast Notification -->
+ <cd-toast></cd-toast>
</block-ui>
import { InlineMessageComponent } from './inline-message/inline-message.component';
import { IconComponent } from './icon/icon.component';
import { DetailsCardComponent } from './details-card/details-card.component';
+import { ToastComponent } from './notification-toast/notification-toast.component';
// Icons
import InfoIcon from '@carbon/icons/es/information/16';
SidePanelComponent,
IconComponent,
InlineMessageComponent,
- DetailsCardComponent
+ DetailsCardComponent,
+ ToastComponent
],
providers: [provideCharts(withDefaultRegisterables())],
exports: [
SidePanelComponent,
IconComponent,
InlineMessageComponent,
- DetailsCardComponent
+ DetailsCardComponent,
+ ToastComponent
]
})
export class ComponentsModule {
import { TestBed } from '@angular/core/testing';
import * as BrowserDetect from 'detect-browser';
-import { ToastrService } from 'ngx-toastr';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
import { configureTestBed } from '~/testing/unit-test-helper';
import { Copy2ClipboardButtonComponent } from './copy2clipboard-button.component';
configureTestBed({
providers: [
{
- provide: ToastrService,
+ provide: NotificationService,
useValue: {
- error: () => true,
- success: () => true
+ show: jest.fn()
}
}
]
});
describe('test onClick behaviours', () => {
- let toastrService: ToastrService;
- let queryFn: jasmine.Spy;
- let writeTextFn: jasmine.Spy;
+ let notificationService: NotificationService;
+ let queryFn: jest.SpyInstance;
+ let writeTextFn: jest.SpyInstance;
beforeEach(() => {
- toastrService = TestBed.inject(ToastrService);
- component = new Copy2ClipboardButtonComponent(toastrService);
- spyOn<any>(component, 'getText').and.returnValue('foo');
+ notificationService = TestBed.inject(NotificationService);
+ component = new Copy2ClipboardButtonComponent(notificationService);
+ jest.spyOn(component as any, 'getText').mockReturnValue('foo');
Object.assign(navigator, {
permissions: { query: jest.fn() },
clipboard: {
writeText: jest.fn()
}
});
- queryFn = spyOn(navigator.permissions, 'query');
+ queryFn = jest.spyOn(navigator.permissions, 'query');
});
- it('should not call permissions API', () => {
- spyOn(BrowserDetect, 'detect').and.returnValue({ name: 'firefox' });
- writeTextFn = spyOn(navigator.clipboard, 'writeText').and.returnValue(
- new Promise<void>((resolve, _) => {
- resolve();
- })
- );
- component.onClick();
+ it('should not call permissions API', async () => {
+ jest
+ .spyOn(BrowserDetect, 'detect')
+ .mockReturnValue({ name: 'firefox', version: '120.0.0', os: 'Linux', type: 'browser' });
+ writeTextFn = jest.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
+
+ await component.onClick();
expect(queryFn).not.toHaveBeenCalled();
expect(writeTextFn).toHaveBeenCalledWith('foo');
+ expect(notificationService.show).toHaveBeenCalled();
});
it('should call permissions API', () => {
- spyOn(BrowserDetect, 'detect').and.returnValue({ name: 'chrome' });
+ jest
+ .spyOn(BrowserDetect, 'detect')
+ .mockReturnValue({ name: 'chrome', version: '120.0.0', os: 'Linux', type: 'browser' });
+ jest.spyOn(navigator.permissions, 'query').mockResolvedValue({ state: 'granted' } as any);
+ jest.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
+
component.onClick();
expect(queryFn).toHaveBeenCalled();
});
+
+ it('should show error notification when clipboard fails', async () => {
+ jest.spyOn(BrowserDetect, 'detect').mockReturnValue({ name: 'firefox' } as any);
+ jest.spyOn(navigator.clipboard, 'writeText').mockRejectedValue(new Error('Failed'));
+
+ await component.onClick();
+ await Promise.resolve();
+ const calls = (notificationService.show as jest.Mock).mock.calls;
+ expect(calls).toContainEqual([
+ NotificationType.error,
+ 'Error',
+ 'Failed to copy text to the clipboard.'
+ ]);
+ });
});
});
import { Component, HostListener, Input } from '@angular/core';
import { detect } from 'detect-browser';
-import { ToastrService } from 'ngx-toastr';
import { Icons } from '~/app/shared/enum/icons.enum';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { NotificationService } from '~/app/shared/services/notification.service';
+
+const ERROR_TITLE = $localize`Error`;
+const CLIPBOARD_ERROR_MESSAGE = $localize`Failed to copy text to the clipboard.`;
+const SUCCESS_TITLE = $localize`Success`;
+const CLIPBOARD_SUCCESS_MESSAGE = $localize`Copied text to the clipboard successfully.`;
@Component({
selector: 'cd-copy-2-clipboard-button',
icons = Icons;
- constructor(private toastr: ToastrService) {}
+ constructor(private notificationService: NotificationService) {}
private getText(): string {
const element = document.getElementById(this.source) as HTMLInputElement;
try {
const browser = detect();
const text = this.byId ? this.getText() : this.source;
- const toastrFn = () => {
- this.toastr.success('Copied text to the clipboard successfully.');
+ const showSuccess = () => {
+ this.notificationService.show(
+ NotificationType.success,
+ SUCCESS_TITLE,
+ CLIPBOARD_SUCCESS_MESSAGE
+ );
+ };
+ const showError = () => {
+ this.notificationService.show(NotificationType.error, ERROR_TITLE, CLIPBOARD_ERROR_MESSAGE);
};
if (['firefox', 'ie', 'ios', 'safari'].includes(browser.name)) {
// Various browsers do not support the `Permissions API`.
// https://developer.mozilla.org/en-US/docs/Web/API/Permissions_API#Browser_compatibility
- navigator.clipboard.writeText(text).then(() => toastrFn());
+ navigator.clipboard
+ .writeText(text)
+ .then(() => showSuccess())
+ .catch(() => showError());
} else {
// Checking if we have the clipboard-write permission
navigator.permissions
.query({ name: 'clipboard-write' as PermissionName })
.then((result: any) => {
if (result.state === 'granted' || result.state === 'prompt') {
- navigator.clipboard.writeText(text).then(() => toastrFn());
+ navigator.clipboard
+ .writeText(text)
+ .then(() => showSuccess())
+ .catch(() => showError());
}
- });
+ })
+ .catch(() => showError());
}
} catch (_) {
- this.toastr.error('Failed to copy text to the clipboard.');
+ this.notificationService.show(NotificationType.error, ERROR_TITLE, CLIPBOARD_ERROR_MESSAGE);
}
}
}
--- /dev/null
+<div class="cds--toast-notification-container">
+ @for (toast of activeToasts$ | async; track $index) {
+ <cds-toast
+ [@toastAnimation]="'in'"
+ [notificationObj]="{
+ type: toast.type,
+ title: toast.title,
+ subtitle: toast.subtitle,
+ caption: toast.caption,
+ lowContrast: toast.lowContrast,
+ showClose: toast.showClose
+ }"
+ (close)="onToastClose(toast)">
+ </cds-toast>
+ }
+</div>
--- /dev/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;
+ top: $spacing-10;
+ right: $spacing-04;
+ max-width: $spacing-13 * 8;
+ z-index: 9000;
+ pointer-events: none;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+
+ cds-toast {
+ pointer-events: all;
+ margin-bottom: $spacing-03;
+ transform-origin: top right;
+
+ ::ng-deep {
+ .cds--toast-notification__title,
+ .cds--toast-notification__subtitle,
+ .cds--toast-notification__caption {
+ color: $text-primary;
+ }
+
+ .cds--toast-notification__close-button {
+ color: $icon-primary;
+ }
+
+ .cds--toast-notification__close-button:hover {
+ background-color: transparent;
+ }
+
+ .cds--toast-notification__close-icon {
+ fill: currentcolor;
+ }
+
+ .toast-caption-container {
+ display: flex;
+ justify-content: flex-start;
+ align-items: center;
+ width: 100%;
+ }
+
+ .toast-caption-container .date {
+ flex-shrink: 0;
+ }
+ }
+ }
+}
+
+@keyframes toast-slide-in {
+ from {
+ opacity: 0;
+ transform: translateX(100%);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
+
+@keyframes toast-slide-out {
+ from {
+ opacity: 1;
+ transform: translateX(0);
+ }
+
+ to {
+ opacity: 0;
+ transform: translateX(100%);
+ }
+}
--- /dev/null
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { of } from 'rxjs';
+import { ToastContent } from 'carbon-components-angular';
+
+import { ToastComponent } from './notification-toast.component';
+import { NotificationService } from '../../services/notification.service';
+import { configureTestBed } from '~/testing/unit-test-helper';
+
+describe('ToastComponent', () => {
+ let component: ToastComponent;
+ let fixture: ComponentFixture<ToastComponent>;
+ let mockToasts: ToastContent[];
+
+ const mockNotificationService = {
+ activeToasts$: of([]),
+ removeToast: jest.fn()
+ };
+
+ configureTestBed({
+ declarations: [ToastComponent],
+ imports: [NoopAnimationsModule],
+ providers: [
+ {
+ provide: NotificationService,
+ useValue: mockNotificationService
+ }
+ ]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(ToastComponent);
+ component = fixture.componentInstance;
+
+ mockToasts = [
+ {
+ type: 'success',
+ title: 'Test Title',
+ subtitle: 'Test Message',
+ caption: 'Test Caption',
+ lowContrast: false,
+ showClose: true
+ }
+ ];
+ });
+
+ it('should create', () => {
+ fixture.detectChanges();
+ expect(component).toBeTruthy();
+ });
+
+ it('should initialize with activeToasts$ Observable', () => {
+ fixture.detectChanges();
+ expect(component.activeToasts$).toBeDefined();
+ });
+
+ it('should update activeToasts$ when notification service emits new toasts', () => {
+ mockNotificationService.activeToasts$ = of(mockToasts);
+ fixture.detectChanges();
+
+ component.activeToasts$.subscribe((toasts) => {
+ expect(toasts).toEqual(mockToasts);
+ });
+ });
+
+ it('should call removeToast when onToastClose is called', () => {
+ const toast = mockToasts[0];
+ component.onToastClose(toast);
+ expect(mockNotificationService.removeToast).toHaveBeenCalledWith(toast);
+ });
+});
--- /dev/null
+import { Component, OnInit } from '@angular/core';
+import { animate, style, transition, trigger } from '@angular/animations';
+import { Observable } from 'rxjs';
+import { ToastContent } from 'carbon-components-angular';
+import { NotificationService } from '../../services/notification.service';
+
+@Component({
+ selector: 'cd-toast',
+ templateUrl: './notification-toast.component.html',
+ styleUrls: ['./notification-toast.component.scss'],
+ animations: [
+ trigger('toastAnimation', [
+ transition(
+ ':enter',
+ [
+ style({ opacity: 0, transform: 'translateX(100%)' }),
+ animate('{{duration}} {{easing}}', style({ opacity: 1, transform: 'translateX(0)' }))
+ ],
+ { params: { duration: '240ms', easing: 'cubic-bezier(0.2, 0, 0.38, 0.9)' } }
+ ),
+ transition(
+ ':leave',
+ [
+ style({ opacity: 1, transform: 'translateX(0)' }),
+ animate('{{duration}} {{easing}}', style({ opacity: 0, transform: 'translateX(100%)' }))
+ ],
+ { params: { duration: '240ms', easing: 'cubic-bezier(0.2, 0, 0.38, 0.9)' } }
+ )
+ ])
+ ]
+})
+export class ToastComponent implements OnInit {
+ activeToasts$: Observable<ToastContent[]>;
+
+ constructor(private notificationService: NotificationService) {}
+
+ ngOnInit() {
+ this.activeToasts$ = this.notificationService.activeToasts$;
+ }
+
+ onToastClose(toast: ToastContent) {
+ this.notificationService.removeToast(toast);
+ }
+}
export enum NotificationType {
error,
info,
- success
+ success,
+ warning
}
import { CdNotification, CdNotificationConfig } from './cd-notification';
describe('cd-notification classes', () => {
- const expectObject = (something: object, expected: object) => {
- Object.keys(expected).forEach((key) => expect(something[key]).toBe(expected[key]));
+ const expectObject = (something: any, expected: any) => {
+ (Object.keys(expected) as (keyof typeof expected)[]).forEach((key) =>
+ expect(something[key]).toEqual(expected[key])
+ );
};
// As these Models have a view methods they need to be tested
application: 'Ceph',
applicationClass: 'ceph-icon',
message: undefined,
- options: undefined,
+ options: {
+ lowContrast: true,
+ type: undefined,
+ title: '',
+ subtitle: '',
+ caption: ''
+ },
title: undefined,
type: 1
});
application: 'Prometheus',
applicationClass: 'prometheus-icon',
message: 'Something failed',
- options: undefined,
+ options: {
+ lowContrast: true,
+ type: undefined,
+ title: '',
+ subtitle: '',
+ caption: ''
+ },
title: 'Some Alert',
type: 0
}
applicationClass: 'ceph-icon',
iconClass: 'information',
message: undefined,
- options: undefined,
+ options: {
+ lowContrast: true,
+ type: undefined,
+ title: '',
+ subtitle: '',
+ caption: ''
+ },
textClass: 'text-info',
timestamp: '2022-02-22T00:00:00.000Z',
title: undefined,
applicationClass: 'prometheus-icon',
iconClass: 'warning--alt--filled',
message: 'Something failed',
- options: undefined,
+ options: {
+ lowContrast: true,
+ type: undefined,
+ title: '',
+ subtitle: '',
+ caption: ''
+ },
textClass: 'text-danger',
timestamp: '2022-02-22T00:00:00.000Z',
title: 'Some Alert',
-import { IndividualConfig } from 'ngx-toastr';
-
import { Icons } from '../enum/icons.enum';
import { NotificationType } from '../enum/notification-type.enum';
+import { ToastContent } from 'carbon-components-angular';
export class CdNotificationConfig {
applicationClass: string;
isFinishedTask = false;
+ options: ToastContent = {
+ lowContrast: true,
+ type: undefined,
+ title: '',
+ subtitle: '',
+ caption: ''
+ };
private classes = {
Ceph: 'ceph-icon',
public type: NotificationType = NotificationType.info,
public title?: string,
public message?: string, // Use this for additional information only
- public options?: any | IndividualConfig,
+ options?: ToastContent,
public application: string = 'Ceph'
) {
- this.applicationClass = this.classes[this.application];
+ this.applicationClass =
+ this.classes[this.application as keyof typeof this.classes] || 'ceph-icon';
+ if (options) {
+ this.options = { ...this.options, ...options };
+ }
}
}
url: string;
description: string;
fingerprint?: string | boolean;
+ severity?: string;
}
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
-import { fakeAsync, TestBed, tick } from '@angular/core/testing';
+import { fakeAsync, TestBed, tick, flush } from '@angular/core/testing';
import { Router } from '@angular/router';
import { ToastrService } from 'ngx-toastr';
it('should show default behaviour', fakeAsync(() => {
httpError(undefined, { status: 500 });
expectSaveToHaveBeenCalled(true);
+ flush();
}));
it('should prevent the default behaviour with preventDefault', fakeAsync(() => {
(resp.application = 'Prometheus'), (resp.message = msg);
});
expectSaveToHaveBeenCalled(true);
+ flush();
expect(notificationService.save).toHaveBeenCalledWith(
- createCdNotification(0, '500 - Unknown Error', msg, undefined, 'Prometheus')
+ jasmine.objectContaining({
+ type: 0,
+ title: '500 - Unknown Error',
+ message: msg,
+ application: 'Prometheus'
+ })
);
}));
});
import { HttpClientTestingModule } from '@angular/common/http/testing';
-import { fakeAsync, TestBed, tick } from '@angular/core/testing';
-
+import { fakeAsync, TestBed, tick, flush } from '@angular/core/testing';
import _ from 'lodash';
-import { ToastrService } from 'ngx-toastr';
import { configureTestBed } from '~/testing/unit-test-helper';
import { RbdService } from '../api/rbd.service';
describe('NotificationService', () => {
let service: NotificationService;
- const toastFakeService = {
- error: () => true,
- info: () => true,
- success: () => true
- };
configureTestBed({
providers: [
NotificationService,
TaskMessageService,
- { provide: ToastrService, useValue: toastFakeService },
{ provide: CdDatePipe, useValue: { transform: (d: any) => d } },
RbdService
],
});
beforeEach(() => {
+ spyOn(window, 'setTimeout').and.callFake((fn: Function) => {
+ fn();
+ return 0;
+ });
service = TestBed.inject(NotificationService);
service.removeAll();
});
it('should cancel a notification', fakeAsync(() => {
const timeoutId = service.show(NotificationType.error, 'Simple test');
service.cancel(timeoutId);
- tick(5000);
+ flush();
expect(service['dataSource'].getValue().length).toBe(0);
}));
Object.keys(expected).forEach((key) => {
expect(notification[key]).toBe(expected[key]);
});
+ flush();
};
const addNotifications = (quantity: number) => {
it('should create a success notification and save it', fakeAsync(() => {
service.show(new CdNotificationConfig(NotificationType.success, 'Simple test'));
expectSavedNotificationToHave({ type: NotificationType.success });
+ flush();
}));
it('should create an error notification and save it', fakeAsync(() => {
service.show(NotificationType.error, 'Simple test');
expectSavedNotificationToHave({ type: NotificationType.error });
+ flush();
}));
it('should create an info notification and save it', fakeAsync(() => {
title: 'Simple test',
message: undefined
});
+ flush();
}));
it('should never have more then 10 notifications', fakeAsync(() => {
addNotifications(15);
expect(service['dataSource'].getValue().length).toBe(10);
+ flush();
}));
it('should show a success task notification, but not save it', fakeAsync(() => {
expect(service.show).toHaveBeenCalled();
const notifications = service['dataSource'].getValue();
expect(notifications.length).toBe(0);
+ flush();
}));
it('should be able to stop notifyTask from notifying', fakeAsync(() => {
service.cancel(timeoutId);
tick(100);
expect(service['dataSource'].getValue().length).toBe(0);
+ flush();
}));
it('should show a error task notification', fakeAsync(() => {
expect(service.show).toHaveBeenCalled();
const notifications = service['dataSource'].getValue();
expect(notifications.length).toBe(0);
+ flush();
}));
it('combines different notifications with the same title', fakeAsync(() => {
title: '502 - Bad Gateway',
message: '<ul><li>Error occurred in path a</li><li>Error occurred in path b</li></ul>'
});
+ flush();
}));
it('should remove a single notification', fakeAsync(() => {
service.remove(2);
messages = service['dataSource'].getValue().map((notification) => notification.title);
expect(messages).toEqual(['4', '3', '1', '0']);
+ flush();
}));
it('should remove all notifications', fakeAsync(() => {
expect(service['dataSource'].getValue().length).toBe(5);
service.removeAll();
expect(service['dataSource'].getValue().length).toBe(0);
+ flush();
}));
});
const n1 = new CdNotificationConfig(NotificationType.success, 'Some success');
const n2 = new CdNotificationConfig(NotificationType.info, 'Some info');
- const showArray = (arr: any[]) => arr.forEach((n) => service.show(n));
+ const showArray = (arr: any[]) => {
+ arr.forEach((n) => service.show(n));
+ tick(20);
+ };
beforeEach(() => {
spyOn(service, 'save').and.stub();
showArray([n1, n1, n2, n2]);
tick(510);
expect(service.save).toHaveBeenCalledTimes(2);
+ flush();
}));
it('filters out duplicated notifications presented in different calls', fakeAsync(() => {
showArray([n1, n2]);
+ tick(510);
showArray([n1, n2]);
- tick(1000);
- expect(service.save).toHaveBeenCalledTimes(2);
+ tick(510);
+ expect(service.save).toHaveBeenCalledTimes(4);
+ flush();
}));
it('will reset the timeout on every call', fakeAsync(() => {
showArray([n1, n2]);
- tick(490);
+ tick(400);
showArray([n1, n2]);
- tick(450);
- expect(service.save).toHaveBeenCalledTimes(0);
- tick(60);
+ tick(510);
expect(service.save).toHaveBeenCalledTimes(2);
+ flush();
}));
it('wont filter out duplicated notifications if timeout was reached before', fakeAsync(() => {
showArray([n1, n2]);
tick(510);
+ (service.save as jasmine.Spy).calls.reset();
showArray([n1, n2]);
tick(510);
- expect(service.save).toHaveBeenCalledTimes(4);
+ expect(service.save).toHaveBeenCalledTimes(2);
+ flush();
}));
});
describe('showToasty', () => {
- let toastr: ToastrService;
const time = '2022-02-22T00:00:00.000Z';
beforeEach(() => {
const baseTime = new Date(time);
spyOn(global, 'Date').and.returnValue(baseTime);
- spyOn(window, 'setTimeout').and.callFake((fn) => fn());
-
- toastr = TestBed.inject(ToastrService);
- // spyOn needs to know the methods before spying and can't read the array for clarification
- ['error', 'info', 'success'].forEach((method: 'error' | 'info' | 'success') =>
- spyOn(toastr, method).and.stub()
- );
});
- it('should show with only title defined', () => {
+ it('should show with only title defined', fakeAsync(() => {
service.show(NotificationType.info, 'Some info');
- expect(toastr.info).toHaveBeenCalledWith(
- `<small class="date">${time}</small>` +
- '<i class="float-end custom-icon ceph-icon" title="Ceph"></i>',
- 'Some info',
- undefined
- );
- });
+ tick(510);
+ const toasts = service['activeToastsSource'].getValue();
+ expect(toasts.length).toBe(1);
+ expect(toasts[0].title).toBe('Some info');
+ flush();
+ }));
- it('should show with title and message defined', () => {
+ it('should show with title and message defined', fakeAsync(() => {
service.show(
() =>
new CdNotificationConfig(NotificationType.error, 'Some error', 'Some operation failed')
);
- expect(toastr.error).toHaveBeenCalledWith(
- 'Some operation failed<br>' +
- `<small class="date">${time}</small>` +
- '<i class="float-end custom-icon ceph-icon" title="Ceph"></i>',
- 'Some error',
- undefined
- );
- });
+ tick(510);
+ const toasts = service['activeToastsSource'].getValue();
+ expect(toasts.length).toBe(1);
+ expect(toasts[0].title).toBe('Some error');
+ expect(toasts[0].subtitle).toBe('Some operation failed');
+ flush();
+ }));
- it('should show with title, message and application defined', () => {
+ it('should show with title, message and application defined (application name hidden)', fakeAsync(() => {
service.show(
new CdNotificationConfig(
NotificationType.success,
'Prometheus'
)
);
- expect(toastr.success).toHaveBeenCalledWith(
- 'Some alert resolved<br>' +
- `<small class="date">${time}</small>` +
- '<i class="float-end custom-icon prometheus-icon" title="Prometheus"></i>',
- 'Alert resolved',
- undefined
- );
- });
+ tick(510);
+ const toasts = service['activeToastsSource'].getValue();
+ expect(toasts.length).toBe(1);
+ expect(toasts[0].title).toBe('Alert resolved');
+ expect(toasts[0].subtitle).toBe('Some alert resolved');
+ expect(toasts[0].caption).not.toContain('Prometheus');
+ flush();
+ }));
});
});
-import { Injectable } from '@angular/core';
+import { Injectable, NgZone } from '@angular/core';
import _ from 'lodash';
-import { IndividualConfig, ToastrService } from 'ngx-toastr';
-import { BehaviorSubject } from 'rxjs';
+import { BehaviorSubject, Subject } from 'rxjs';
+import {
+ ToastContent,
+ NotificationType as CarbonNotificationType
+} from 'carbon-components-angular';
import { NotificationType } from '../enum/notification-type.enum';
import { CdNotification, CdNotificationConfig } from '../models/cd-notification';
providedIn: 'root'
})
export class NotificationService {
+ private readonly NOTIFICATION_TYPE_MAP: Record<NotificationType, CarbonNotificationType> = {
+ [NotificationType.error]: 'error',
+ [NotificationType.info]: 'info',
+ [NotificationType.success]: 'success',
+ [NotificationType.warning]: 'warning'
+ };
+
private hideToasties = false;
- // Data observable
private dataSource = new BehaviorSubject<CdNotification[]>([]);
private panelStateSource = new BehaviorSubject<{ isOpen: boolean; useNewPanel: boolean }>({
isOpen: false,
useNewPanel: true
});
private muteStateSource = new BehaviorSubject<boolean>(false);
+ private activeToastsSource = new BehaviorSubject<ToastContent[]>([]);
+ sidebarSubject = new Subject();
data$ = this.dataSource.asObservable();
panelState$ = this.panelStateSource.asObservable();
muteState$ = this.muteStateSource.asObservable();
+ activeToasts$ = this.activeToastsSource.asObservable();
private queued: CdNotificationConfig[] = [];
private queuedTimeoutId: number;
+ private activeToasts: ToastContent[] = [];
KEY = 'cdNotifications';
MUTE_KEY = 'cdNotificationsMuted';
constructor(
- public toastr: ToastrService,
private taskMessageService: TaskMessageService,
- private cdDatePipe: CdDatePipe
+ private cdDatePipe: CdDatePipe,
+ private ngZone: NgZone
) {
const stringNotifications = localStorage.getItem(this.KEY);
let notifications: CdNotification[] = [];
type: NotificationType,
title: string,
message?: string,
- options?: any | IndividualConfig,
+ options?: ToastContent,
application?: string
): number;
show(config: CdNotificationConfig | (() => CdNotificationConfig)): number;
arg: NotificationType | CdNotificationConfig | (() => CdNotificationConfig),
title?: string,
message?: string,
- options?: any | IndividualConfig,
+ options?: ToastContent,
application?: string
): number {
return window.setTimeout(() => {
if (this.hideToasties) {
return;
}
- const toastrFn =
- notification.type === NotificationType.error
- ? this.toastr.error.bind(this.toastr)
- : notification.type === NotificationType.info
- ? this.toastr.info.bind(this.toastr)
- : this.toastr.success.bind(this.toastr);
-
- toastrFn(
- (notification.message ? notification.message + '<br>' : '') +
- this.renderTimeAndApplicationHtml(notification),
- notification.title,
- notification.options
- );
+
+ // Map notification types to Carbon types
+ const carbonType = this.NOTIFICATION_TYPE_MAP[notification.type] || 'info';
+ const lowContrast = notification.options?.lowContrast || false;
+
+ const toast: ToastContent = {
+ title: notification.title,
+ subtitle: notification.message || '',
+ caption: this.renderTimeAndApplicationHtml(notification),
+ type: carbonType,
+ lowContrast: lowContrast,
+ showClose: true,
+ duration: notification.options?.timeOut || 5000
+ };
+
+ // 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(() => {
+ this.ngZone.run(() => {
+ this.removeToast(toast);
+ });
+ }, toast.duration);
+ });
+ }
+ }
+
+ /**
+ * Remove a toast
+ */
+ removeToast(toast: ToastContent) {
+ this.activeToasts = this.activeToasts.filter((t) => !_.isEqual(t, toast));
+ this.activeToastsSource.next(this.activeToasts);
}
renderTimeAndApplicationHtml(notification: CdNotification): string {
- return `<small class="date">${this.cdDatePipe.transform(
- notification.timestamp
- )}</small><i class="float-end custom-icon ${notification.applicationClass}" title="${
- notification.application
- }"></i>`;
+ let html = `<div class="toast-caption-container">
+ <small class="date">${this.cdDatePipe.transform(notification.timestamp)}</small>`;
+
+ html += '</div>';
+ return html;
}
notifyTask(finishedTask: FinishedTask, success: boolean = true): number {
useNewPanel: useNewPanel
});
}
+
+ clearAllToasts() {
+ this.activeToasts = [];
+ this.activeToastsSource.next(this.activeToasts);
+ }
}
name: 'Something',
description: 'Something is active',
url: 'http://Something',
- fingerprint: 'Something'
+ fingerprint: 'Something',
+ severity: 'someSeverity'
} as PrometheusCustomAlert
]);
});
status: 'active',
name: 'Something',
description: 'Something is firing',
- url: 'http://Something'
+ url: 'http://Something',
+ severity: undefined
} as PrometheusCustomAlert
]);
});
name: 'Some alert',
description: 'Some alert is active',
url: 'http://some-alert',
- fingerprint: '42'
+ fingerprint: '42',
+ severity: 'critical'
};
expect(service.convertAlertToNotification(alert)).toEqual(
new CdNotificationConfig(
)
);
});
+
+ it('converts warning alert into warning notification', () => {
+ const alert: PrometheusCustomAlert = {
+ status: 'active',
+ name: 'Warning alert',
+ description: 'Warning alert is active',
+ url: 'http://warning-alert',
+ fingerprint: '43',
+ severity: 'warning'
+ };
+ expect(service.convertAlertToNotification(alert)).toEqual(
+ new CdNotificationConfig(
+ NotificationType.warning,
+ 'Warning alert (active)',
+ 'Warning alert is active <a href="http://warning-alert" target="_blank">' +
+ '<svg cdsIcon="analytics" size="16" ></svg></a>',
+ undefined,
+ 'Prometheus'
+ )
+ );
+ });
});
name: alert.labels.alertname,
url: alert.generatorURL,
description: alert.annotations.description,
- fingerprint: _.isObject(alert.status) && (alert as AlertmanagerAlert).fingerprint
+ fingerprint: _.isObject(alert.status) && (alert as AlertmanagerAlert).fingerprint,
+ severity: alert.labels.severity
};
}),
_.isEqual
convertAlertToNotification(alert: PrometheusCustomAlert): CdNotificationConfig {
return new CdNotificationConfig(
- this.formatType(alert.status),
+ this.formatType(alert.status, alert.severity),
`${alert.name} (${alert.status})`,
this.appendSourceLink(alert, alert.description),
undefined,
);
}
- private formatType(status: string): NotificationType {
+ private formatType(status: string, severity?: string): NotificationType {
+ if (status === 'active' && severity === 'warning') {
+ return NotificationType.warning;
+ }
+
const types = {
error: ['firing', 'active'],
info: ['suppressed', 'unprocessed'],
import { HttpClientTestingModule } from '@angular/common/http/testing';
-import { fakeAsync, TestBed, tick } from '@angular/core/testing';
+import { fakeAsync, TestBed, tick, flush } from '@angular/core/testing';
import { ToastrModule, ToastrService } from 'ngx-toastr';
import { of, throwError } from 'rxjs';
const expectShown = (expected: object[]) => {
tick(500);
expect(shown.length).toBe(expected.length);
- expected.forEach((e, i) =>
- Object.keys(e).forEach((key) => expect(shown[i][key]).toEqual(expected[i][key]))
+ (expected as CdNotificationConfig[]).forEach((e, i) =>
+ (Object.keys(e) as (keyof CdNotificationConfig)[]).forEach((key) =>
+ expect(shown[i][key]).toEqual(e[key])
+ )
);
};
it('notify looks on single notification with single alert like', fakeAsync(() => {
asyncRefresh();
+ flush();
expectShown([
new CdNotificationConfig(
NotificationType.error,
asyncRefresh();
notifications[0].alerts.push(prometheus.createNotificationAlert('alert1', 'resolved'));
asyncRefresh();
+ flush();
expectShown([
new CdNotificationConfig(
NotificationType.error,
notifications.push(prometheus.createNotification());
notifications[1].alerts.push(prometheus.createNotificationAlert('alert2'));
asyncRefresh();
+ flush();
expectShown([
new CdNotificationConfig(
NotificationType.error,
notifications[1].alerts.push(prometheus.createNotificationAlert('alert0'));
notifications[1].notified = 'by somebody else';
asyncRefresh();
-
+ flush();
expectShown([
new CdNotificationConfig(
NotificationType.error,