Feature: better toast notifications management (#8980)

This commit is contained in:
shamoon
2025-02-06 23:06:16 -08:00
committed by GitHub
parent e08606af6e
commit b8bdc10f25
21 changed files with 690 additions and 324 deletions

View File

@@ -30,12 +30,13 @@
</div>
</div>
<ul ngbNav class="order-sm-3">
<pngx-toasts-dropdown></pngx-toasts-dropdown>
<li ngbDropdown class="nav-item dropdown">
<button class="btn border-0" id="userDropdown" ngbDropdownToggle>
<span class="small me-2 d-none d-sm-inline">
<button class="btn ps-1 border-0" id="userDropdown" ngbDropdownToggle>
<i-bs width="1.3em" height="1.3em" name="person-circle"></i-bs>
<span class="small ms-2 d-none d-sm-inline">
{{this.settingsService.displayName}}
</span>
<i-bs width="1.3em" height="1.3em" name="person-circle"></i-bs>
</button>
<div ngbDropdownMenu class="dropdown-menu-end shadow me-2" aria-labelledby="userDropdown">
<div class="d-sm-none">

View File

@@ -250,8 +250,8 @@ main {
}
}
.dropdown.show .dropdown-toggle,
.dropdown-toggle:hover {
:host ::ng-deep .dropdown.show .dropdown-toggle,
:host ::ng-deep .dropdown-toggle:hover {
opacity: 0.7;
}

View File

@@ -48,6 +48,7 @@ import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profil
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
import { GlobalSearchComponent } from './global-search/global-search.component'
import { ToastsDropdownComponent } from './toasts-dropdown/toasts-dropdown.component'
@Component({
selector: 'pngx-app-frame',
@@ -57,6 +58,7 @@ import { GlobalSearchComponent } from './global-search/global-search.component'
GlobalSearchComponent,
DocumentTitlePipe,
IfPermissionsDirective,
ToastsDropdownComponent,
RouterModule,
NgClass,
NgbDropdownModule,

View File

@@ -0,0 +1,28 @@
<li ngbDropdown class="nav-item" (openChange)="onOpenChange($event)">
@if (toasts.length) {
<span class="badge rounded-pill z-3 pe-none bg-secondary me-2 position-absolute top-0 left-0">{{ toasts.length }}</span>
}
<button class="btn border-0" id="notificationsDropdown" ngbDropdownToggle>
<i-bs width="1.3em" height="1.3em" name="bell"></i-bs>
</button>
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="notificationsDropdown">
<div class="btn-toolbar align-items-center" role="toolbar">
<h6 i18n>Notifications</h6>
<div class="btn-group ms-auto">
<button class="btn btn-sm btn-outline-secondary mb-2 ms-auto"
(click)="toastService.clearToasts()"
[disabled]="toasts.length === 0"
i18n>Clear All</button>
</div>
</div>
@if (toasts.length === 0) {
<p class="text-center mb-0 small text-muted"><em i18n>No notifications</em></p>
}
<div class="scroll-list">
@for (toast of toasts; track toast.id) {
<pngx-toast [autohide]="false" [toast]="toast" (hidden)="onHidden(toast)" (close)="toastService.closeToast(toast)"></pngx-toast>
}
</div>
</div>
</li>

View File

@@ -0,0 +1,22 @@
.dropdown-menu {
width: var(--pngx-toast-max-width);
}
.dropdown-menu .scroll-list {
max-height: 500px;
overflow-y: auto;
}
.dropdown-toggle::after {
display: none;
}
.dropdown-item {
white-space: initial;
}
@media screen and (max-width: 400px) {
:host ::ng-deep .dropdown-menu-end {
right: -3rem;
}
}

View File

@@ -0,0 +1,112 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import {
ComponentFixture,
TestBed,
discardPeriodicTasks,
fakeAsync,
flush,
} from '@angular/core/testing'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { Subject } from 'rxjs'
import { Toast, ToastService } from 'src/app/services/toast.service'
import { ToastsDropdownComponent } from './toasts-dropdown.component'
const toasts = [
{
id: 'abc-123',
content: 'foo bar',
delay: 5000,
},
{
id: 'def-123',
content: 'Error 1 content',
delay: 5000,
error: 'Error 1 string',
},
{
id: 'ghi-123',
content: 'Error 2 content',
delay: 5000,
error: {
url: 'https://example.com',
status: 500,
statusText: 'Internal Server Error',
message: 'Internal server error 500 message',
error: { detail: 'Error 2 message details' },
},
},
]
describe('ToastsDropdownComponent', () => {
let component: ToastsDropdownComponent
let fixture: ComponentFixture<ToastsDropdownComponent>
let toastService: ToastService
let toastsSubject: Subject<Toast[]> = new Subject()
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [
ToastsDropdownComponent,
NgxBootstrapIconsModule.pick(allIcons),
],
providers: [
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
}).compileComponents()
fixture = TestBed.createComponent(ToastsDropdownComponent)
toastService = TestBed.inject(ToastService)
jest.spyOn(toastService, 'getToasts').mockReturnValue(toastsSubject)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should call getToasts and return toasts', fakeAsync(() => {
const spy = jest.spyOn(toastService, 'getToasts')
component.ngOnInit()
toastsSubject.next(toasts)
fixture.detectChanges()
expect(spy).toHaveBeenCalled()
expect(component.toasts).toContainEqual({
id: 'abc-123',
content: 'foo bar',
delay: 5000,
})
component.ngOnDestroy()
flush()
discardPeriodicTasks()
}))
it('should show a toast', fakeAsync(() => {
component.ngOnInit()
toastsSubject.next(toasts)
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('foo bar')
component.ngOnDestroy()
flush()
discardPeriodicTasks()
}))
it('should toggle suppressPopupToasts', fakeAsync((finish) => {
component.ngOnInit()
fixture.detectChanges()
toastsSubject.next(toasts)
const spy = jest.spyOn(toastService, 'suppressPopupToasts', 'set')
component.onOpenChange(true)
expect(spy).toHaveBeenCalledWith(true)
component.ngOnDestroy()
flush()
discardPeriodicTasks()
}))
})

View File

@@ -0,0 +1,42 @@
import { Component, OnDestroy, OnInit } from '@angular/core'
import {
NgbDropdownModule,
NgbProgressbarModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Subscription } from 'rxjs'
import { Toast, ToastService } from 'src/app/services/toast.service'
import { ToastComponent } from '../../common/toast/toast.component'
@Component({
selector: 'pngx-toasts-dropdown',
templateUrl: './toasts-dropdown.component.html',
styleUrls: ['./toasts-dropdown.component.scss'],
imports: [
ToastComponent,
NgbDropdownModule,
NgbProgressbarModule,
NgxBootstrapIconsModule,
],
})
export class ToastsDropdownComponent implements OnInit, OnDestroy {
constructor(public toastService: ToastService) {}
private subscription: Subscription
public toasts: Toast[] = []
ngOnDestroy(): void {
this.subscription?.unsubscribe()
}
ngOnInit(): void {
this.subscription = this.toastService.getToasts().subscribe((toasts) => {
this.toasts = [...toasts]
})
}
onOpenChange(open: boolean): void {
this.toastService.suppressPopupToasts = open
}
}