import { CommonModule } from '@angular/common';
-import { NgModule } from '@angular/core';
+import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { RouterModule } from '@angular/router';
import { NgbCollapseModule, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap';
DialogModule,
GridModule,
BreadcrumbModule,
- ModalModule
+ ModalModule,
+ ToggleModule,
+ ButtonModule
} from 'carbon-components-angular';
import { AppRoutingModule } from '~/app/app-routing.module';
import { IdentityComponent } from './identity/identity.component';
import { NavigationComponent } from './navigation/navigation.component';
import { NotificationsComponent } from './notifications/notifications.component';
+import { NotificationPanelComponent } from './notification-panel/notification-panel.component';
+import { NotificationHeaderComponent } from './notification-panel/header/notification-header.component';
// Icons
import UserFilledIcon from '@carbon/icons/es/user--filled/20';
DialogModule,
GridModule,
BreadcrumbModule,
- ModalModule
+ ModalModule,
+ ToggleModule,
+ ButtonModule
],
declarations: [
AboutComponent,
BreadcrumbsComponent,
NavigationComponent,
NotificationsComponent,
+ NotificationPanelComponent,
+ NotificationHeaderComponent,
DashboardHelpComponent,
AdministrationComponent,
IdentityComponent
],
- providers: [ModalCdsService],
- exports: [NavigationComponent, BreadcrumbsComponent]
+ exports: [NavigationComponent, BreadcrumbsComponent],
+ schemas: [CUSTOM_ELEMENTS_SCHEMA],
+ providers: [ModalCdsService]
})
export class NavigationModule {
constructor(private iconService: IconService) {
);
}
toggleSidebar() {
- this.notificationService.toggleSidebar();
+ this.notificationService.toggleSidebar(true, true);
}
trackByFn(item: any) {
return item;
--- /dev/null
+<div class="notification-header">
+ <div class="notification-header__top">
+ <div class="notification-header__title">
+ <cds-text i18n>Tasks and Notifications</cds-text>
+ </div>
+
+ <button
+ i18n
+ cdsButton="ghost"
+ size="sm"
+ (click)="onDismissAll()"
+ class="notification-header__dismiss-btn">
+ Dismiss all
+ </button>
+ </div>
+
+ <div class="notification-header__toggle">
+ <cds-toggle
+ [checked]="isMuted"
+ size="sm"
+ (checkedChange)="onToggleMute()"
+ label="Mute notifications"
+ i18n-label
+ hideLabel="true"> <!--hides the toggle state values (like "On/Off" in the toggle button)-->
+ </cds-toggle>
+ </div>
+</div>
--- /dev/null
+@use '@carbon/styles/scss/type';
+@use '@carbon/styles/scss/spacing';
+@use '@carbon/styles/scss/theme';
+
+.notification-header {
+ display: flex;
+ flex-direction: column;
+ padding: spacing.$spacing-04;
+ border-bottom: 1px solid theme.$border-subtle-01;
+ background-color: theme.$layer-01;
+
+ &__top {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ width: 100%;
+ margin-bottom: spacing.$spacing-03;
+ }
+
+ &__title {
+ h4 {
+ @include type.type-style('heading-compact-01');
+
+ color: theme.$text-primary;
+ margin: 0;
+ }
+ }
+
+ &__dismiss-btn {
+ color: theme.$text-primary;
+
+ &:hover {
+ color: theme.$link-primary;
+ }
+ }
+
+ &__toggle {
+ cds-toggle {
+ margin: 0;
+
+ ::ng-deep {
+ .cds--toggle__label-text {
+ color: theme.$text-primary;
+ }
+
+ .cds--toggle__label {
+ color: theme.$text-primary;
+ }
+
+ .cds--toggle__text {
+ color: theme.$text-primary;
+ }
+ }
+ }
+ }
+}
--- /dev/null
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { NotificationHeaderComponent } from './notification-header.component';
+import { NotificationService } from '../../../../shared/services/notification.service';
+import { BehaviorSubject } from 'rxjs';
+
+describe('NotificationHeaderComponent', () => {
+ let component: NotificationHeaderComponent;
+ let fixture: ComponentFixture<NotificationHeaderComponent>;
+ let notificationService: NotificationService;
+ let muteStateSubject: BehaviorSubject<boolean>;
+
+ beforeEach(async () => {
+ muteStateSubject = new BehaviorSubject<boolean>(false);
+ await TestBed.configureTestingModule({
+ declarations: [NotificationHeaderComponent],
+ providers: [
+ {
+ provide: NotificationService,
+ useValue: {
+ muteState$: muteStateSubject.asObservable(),
+ removeAll: jasmine.createSpy('removeAll'),
+ suspendToasties: jasmine.createSpy('suspendToasties')
+ }
+ }
+ ]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(NotificationHeaderComponent);
+ component = fixture.componentInstance;
+ notificationService = TestBed.inject(NotificationService);
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should initialize with default mute state', () => {
+ expect(component.isMuted).toBe(false);
+ });
+
+ it('should update mute state when subscription emits', () => {
+ muteStateSubject.next(true);
+ fixture.detectChanges();
+ expect(component.isMuted).toBe(true);
+ });
+
+ it('should emit dismissAll event and call removeAll on dismiss', () => {
+ spyOn(component.dismissAll, 'emit');
+
+ component.onDismissAll();
+
+ expect(component.dismissAll.emit).toHaveBeenCalled();
+ expect(notificationService.removeAll).toHaveBeenCalled();
+ });
+
+ it('should toggle mute state', () => {
+ component.isMuted = false;
+ component.onToggleMute();
+ expect(notificationService.suspendToasties).toHaveBeenCalledWith(true);
+
+ component.isMuted = true;
+ component.onToggleMute();
+ expect(notificationService.suspendToasties).toHaveBeenCalledWith(false);
+ });
+
+ it('should unsubscribe on destroy', () => {
+ spyOn(component['subs'], 'unsubscribe');
+ component.ngOnDestroy();
+ expect(component['subs'].unsubscribe).toHaveBeenCalled();
+ });
+});
--- /dev/null
+import { Component, Output, EventEmitter, OnInit, OnDestroy } from '@angular/core';
+import { NotificationService } from '../../../../shared/services/notification.service';
+import { Subscription } from 'rxjs';
+
+@Component({
+ selector: 'cd-notification-header',
+ templateUrl: './notification-header.component.html',
+ styleUrls: ['./notification-header.component.scss']
+})
+export class NotificationHeaderComponent implements OnInit, OnDestroy {
+ @Output() dismissAll = new EventEmitter<void>();
+
+ isMuted = false;
+ private subs = new Subscription();
+
+ constructor(private notificationService: NotificationService) {}
+
+ ngOnInit(): void {
+ this.subs.add(
+ this.notificationService.muteState$.subscribe((isMuted) => {
+ this.isMuted = isMuted;
+ })
+ );
+ }
+
+ ngOnDestroy(): void {
+ this.subs.unsubscribe();
+ }
+
+ onDismissAll(): void {
+ this.dismissAll.emit();
+ this.notificationService.removeAll();
+ }
+
+ onToggleMute(): void {
+ this.notificationService.suspendToasties(!this.isMuted);
+ }
+}
--- /dev/null
+<div class="notification-panel">
+ <cd-notification-header></cd-notification-header>
+ <!-- Body and footer components will be added here later -->
+</div>
--- /dev/null
+@use '@carbon/styles/scss/theme';
+@use '@carbon/styles/scss/spacing';
+@use '@carbon/styles/scss/themes';
+@use '@carbon/styles/scss/theme' as *;
+
+.notification-panel {
+ @include theme.theme(themes.$g10);
+
+ position: absolute;
+ top: spacing.$spacing-09;
+ right: 0;
+ width: 400px; // Keep original width as it doesn't map to a spacing token
+ background-color: $layer-01;
+ box-shadow: $shadow;
+ border: 1px solid $border-subtle-01;
+ z-index: 6000;
+ color: $text-primary;
+}
--- /dev/null
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { NotificationPanelComponent } from './notification-panel.component';
+import { NotificationService } from '../../../shared/services/notification.service';
+
+describe('NotificationPanelComponent', () => {
+ let component: NotificationPanelComponent;
+ let fixture: ComponentFixture<NotificationPanelComponent>;
+ let notificationService: NotificationService;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ declarations: [NotificationPanelComponent],
+ providers: [
+ {
+ provide: NotificationService,
+ useValue: {
+ toggleSidebar: jasmine.createSpy('toggleSidebar')
+ }
+ }
+ ]
+ }).compileComponents();
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(NotificationPanelComponent);
+ component = fixture.componentInstance;
+ notificationService = TestBed.inject(NotificationService);
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('handleClickOutside', () => {
+ it('should close sidebar when clicking outside', () => {
+ // Create a click event outside the component
+ const outsideClickEvent = new MouseEvent('click', {
+ bubbles: true,
+ cancelable: true
+ });
+ document.dispatchEvent(outsideClickEvent);
+
+ expect(notificationService.toggleSidebar).toHaveBeenCalledWith(false, true);
+ });
+
+ it('should not close sidebar when clicking inside', () => {
+ // Create a click event inside the component
+ const insideClickEvent = new MouseEvent('click', {
+ bubbles: true,
+ cancelable: true
+ });
+
+ const componentElement = fixture.nativeElement;
+ componentElement.dispatchEvent(insideClickEvent);
+
+ expect(notificationService.toggleSidebar).not.toHaveBeenCalled();
+ });
+ });
+});
--- /dev/null
+import { Component, ElementRef, HostListener } from '@angular/core';
+import { NotificationService } from '../../../shared/services/notification.service';
+
+@Component({
+ selector: 'cd-notification-panel',
+ templateUrl: './notification-panel.component.html',
+ styleUrls: ['./notification-panel.component.scss']
+})
+export class NotificationPanelComponent {
+ constructor(public notificationService: NotificationService, private elementRef: ElementRef) {}
+
+ @HostListener('document:click', ['$event'])
+ handleClickOutside(event: Event) {
+ const clickedInside = this.elementRef.nativeElement.contains(event.target);
+ if (!clickedInside) {
+ this.notificationService.toggleSidebar(false, true);
+ }
+ }
+}
<a i18n-title
title="Tasks and Notifications"
[ngClass]="{ 'running': hasRunningTasks }"
- >
- <svg cdsIcon="notification"
- size="20"
- title="notification"></svg>
- <span class="dot"
- *ngIf="hasNotifications">
- </span>
+ (click)="togglePanel($event)">
+ <div class="notification-icon-wrapper">
+ <svg cdsIcon="notification"
+ size="20"
+ title="notification"></svg>
+ <span class="notification-count"
+ *ngIf="notificationCount > 0">
+ {{ notificationCount }}
+ </span>
+ </div>
<span class="d-md-none"
i18n>Tasks and Notifications</span>
</a>
+
+<cd-notification-panel *ngIf="isPanelOpen && useNewPanel"></cd-notification-panel>
+<cd-notifications-sidebar *ngIf="isPanelOpen && !useNewPanel"></cd-notifications-sidebar>
@use './src/styles/vendor/variables' as vv;
+@use '@carbon/styles/scss/spacing';
+@use '@carbon/styles/scss/theme' as *;
+@use '@carbon/styles/scss/type';
.running i {
color: vv.$primary;
border-color: vv.$primary-500;
}
}
+
+.notification-icon-wrapper {
+ position: relative;
+ display: inline-flex;
+ padding: spacing.$spacing-04;
+ cursor: pointer;
+ border-radius: spacing.$spacing-01;
+ transition: background-color 0.2s ease;
+}
+
+.notification-count {
+ position: absolute;
+ top: spacing.$spacing-01;
+ right: spacing.$spacing-01;
+ min-width: spacing.$spacing-04;
+ height: spacing.$spacing-04;
+ padding: 0 spacing.$spacing-01;
+ border-radius: spacing.$spacing-02;
+ background-color: $support-error;
+ color: $text-on-color;
+ font-size: spacing.$spacing-04;
+ line-height: spacing.$spacing-04;
+ text-align: center;
+ font-weight: 500;
+}
icons = Icons;
hasRunningTasks = false;
hasNotifications = false;
+ isPanelOpen = false;
+ useNewPanel = true;
+ notificationCount = 0;
private subs = new Subscription();
constructor(
this.subs.add(
this.notificationService.data$.subscribe((notifications: CdNotification[]) => {
this.hasNotifications = notifications.length > 0;
+ this.notificationCount = notifications.length;
})
);
+
+ this.subs.add(
+ this.notificationService.panelState$.subscribe((state) => {
+ this.isPanelOpen = state.isOpen;
+ this.useNewPanel = state.useNewPanel;
+ })
+ );
+ }
+
+ togglePanel(event: Event) {
+ event.preventDefault();
+ event.stopPropagation();
+ this.notificationService.toggleSidebar(!this.isPanelOpen, this.useNewPanel);
}
ngOnDestroy(): void {
it('should always close if sidebarSubject value is true', fakeAsync(() => {
// Closed before next value
expect(component.isSidebarOpened).toBeFalsy();
- notificationService.sidebarSubject.next(true);
+ notificationService.toggleSidebar(true, true);
tick();
expect(component.isSidebarOpened).toBeFalsy();
// Opened before next value
component.isSidebarOpened = true;
expect(component.isSidebarOpened).toBeTruthy();
- notificationService.sidebarSubject.next(true);
+ notificationService.toggleSidebar(true, true);
tick();
expect(component.isSidebarOpened).toBeFalsy();
}));
it('should toggle sidebar visibility if sidebarSubject value is false', () => {
// Closed before next value
expect(component.isSidebarOpened).toBeFalsy();
- notificationService.sidebarSubject.next(false);
+ notificationService.toggleSidebar(true, false);
expect(component.isSidebarOpened).toBeTruthy();
// Opened before next value
component.isSidebarOpened = true;
expect(component.isSidebarOpened).toBeTruthy();
- notificationService.sidebarSubject.next(false);
+ notificationService.toggleSidebar(false, false);
expect(component.isSidebarOpened).toBeFalsy();
});
});
);
this.subs.add(
- this.notificationService.sidebarSubject.subscribe((forceClose) => {
- if (forceClose) {
- this.isSidebarOpened = false;
- } else {
- this.isSidebarOpened = !this.isSidebarOpened;
- }
-
- window.clearTimeout(this.timeout);
- this.timeout = window.setTimeout(() => {
- this.cdRef.detectChanges();
- }, 0);
+ this.notificationService.panelState$.subscribe((state) => {
+ this.isSidebarOpened = state.isOpen && !state.useNewPanel;
+ this.cdRef.detectChanges();
})
);
}
closeSidebar() {
- this.isSidebarOpened = false;
+ this.notificationService.toggleSidebar(false, false);
}
trackByFn(index: number) {
import _ from 'lodash';
import { IndividualConfig, ToastrService } from 'ngx-toastr';
-import { BehaviorSubject, Subject } from 'rxjs';
+import { BehaviorSubject } from 'rxjs';
import { NotificationType } from '../enum/notification-type.enum';
import { CdNotification, CdNotificationConfig } from '../models/cd-notification';
// Data observable
private dataSource = new BehaviorSubject<CdNotification[]>([]);
- data$ = this.dataSource.asObservable();
+ private panelStateSource = new BehaviorSubject<{ isOpen: boolean; useNewPanel: boolean }>({
+ isOpen: false,
+ useNewPanel: true
+ });
+ private muteStateSource = new BehaviorSubject<boolean>(false);
- // Sidebar observable
- sidebarSubject = new Subject();
+ data$ = this.dataSource.asObservable();
+ panelState$ = this.panelStateSource.asObservable();
+ muteState$ = this.muteStateSource.asObservable();
private queued: CdNotificationConfig[] = [];
private queuedTimeoutId: number;
KEY = 'cdNotifications';
+ MUTE_KEY = 'cdNotificationsMuted';
constructor(
public toastr: ToastrService,
}
this.dataSource.next(notifications);
+
+ // Load mute state from localStorage
+ const isMuted = localStorage.getItem(this.MUTE_KEY) === 'true';
+ this.hideToasties = isMuted;
+ this.muteStateSource.next(isMuted);
}
/**
if (this.hideToasties) {
return;
}
- this.toastr[['error', 'info', 'success'][notification.type]](
+ 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,
*/
suspendToasties(suspend: boolean) {
this.hideToasties = suspend;
+ this.muteStateSource.next(suspend);
+ localStorage.setItem(this.MUTE_KEY, suspend.toString());
}
- toggleSidebar(forceClose = false) {
- this.sidebarSubject.next(forceClose);
+ /**
+ * Toggle the sidebar/panel visibility
+ * @param isOpen whether to open or close the panel
+ * @param useNewPanel which panel type to use
+ */
+ toggleSidebar(isOpen: boolean, useNewPanel: boolean = true) {
+ this.panelStateSource.next({
+ isOpen: isOpen,
+ useNewPanel: useNewPanel
+ });
}
}