diff --git a/src-ui/src/app/components/app-frame/app-frame.component.scss b/src-ui/src/app/components/app-frame/app-frame.component.scss
index 9d1110ef4..718d7ea41 100644
--- a/src-ui/src/app/components/app-frame/app-frame.component.scss
+++ b/src-ui/src/app/components/app-frame/app-frame.component.scss
@@ -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;
}
diff --git a/src-ui/src/app/components/app-frame/app-frame.component.ts b/src-ui/src/app/components/app-frame/app-frame.component.ts
index 4990beb09..fabcbf7d1 100644
--- a/src-ui/src/app/components/app-frame/app-frame.component.ts
+++ b/src-ui/src/app/components/app-frame/app-frame.component.ts
@@ -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,
diff --git a/src-ui/src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.html b/src-ui/src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.html
new file mode 100644
index 000000000..6e49c1763
--- /dev/null
+++ b/src-ui/src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.html
@@ -0,0 +1,28 @@
+
+
+ @if (toasts.length) {
+ {{ toasts.length }}
+ }
+
+
+
+ @if (toasts.length === 0) {
+
No notifications
+ }
+
+
+
diff --git a/src-ui/src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.scss b/src-ui/src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.scss
new file mode 100644
index 000000000..2332e710d
--- /dev/null
+++ b/src-ui/src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.scss
@@ -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;
+ }
+}
diff --git a/src-ui/src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.spec.ts b/src-ui/src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.spec.ts
new file mode 100644
index 000000000..33b948f30
--- /dev/null
+++ b/src-ui/src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.spec.ts
@@ -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
+ let toastService: ToastService
+ let toastsSubject: Subject = 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()
+ }))
+})
diff --git a/src-ui/src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.ts b/src-ui/src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.ts
new file mode 100644
index 000000000..c04d758af
--- /dev/null
+++ b/src-ui/src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.ts
@@ -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
+ }
+}
diff --git a/src-ui/src/app/components/common/toast/toast.component.html b/src-ui/src/app/components/common/toast/toast.component.html
new file mode 100644
index 000000000..ede75ddea
--- /dev/null
+++ b/src-ui/src/app/components/common/toast/toast.component.html
@@ -0,0 +1,56 @@
+
+ @if (autohide) {
+
+ {{ toast.delayRemaining / 1000 | number: '1.0-0' }} seconds
+ }
+
+ @if (!toast.error) {
+
+ }
+ @if (toast.error) {
+
+ }
+
+
{{toast.content}}
+ @if (toast.error) {
+
+
+ @if (isDetailedError(toast.error)) {
+
+ - URL
+ - {{ toast.error.url }}
+ - Status
+ - {{ toast.error.status }} {{ toast.error.statusText }}
+ - Error
+ - {{ getErrorText(toast.error) }}
+
+ }
+
+
+
+
+
+
+
+ }
+ @if (toast.action) {
+
+ }
+
+
+
+
diff --git a/src-ui/src/app/components/common/toast/toast.component.scss b/src-ui/src/app/components/common/toast/toast.component.scss
new file mode 100644
index 000000000..3783445de
--- /dev/null
+++ b/src-ui/src/app/components/common/toast/toast.component.scss
@@ -0,0 +1,20 @@
+::ng-deep .toast-body {
+ position: relative;
+}
+
+::ng-deep .toast.error {
+ border-color: hsla(350, 79%, 40%, 0.4); // bg-danger
+}
+
+::ng-deep .toast.error .toast-body {
+ background-color: hsla(350, 79%, 40%, 0.8); // bg-danger
+ border-top-left-radius: inherit;
+ border-top-right-radius: inherit;
+ border-bottom-left-radius: inherit;
+ border-bottom-right-radius: inherit;
+}
+
+.progress {
+ background-color: var(--pngx-primary);
+ opacity: .07;
+}
diff --git a/src-ui/src/app/components/common/toast/toast.component.spec.ts b/src-ui/src/app/components/common/toast/toast.component.spec.ts
new file mode 100644
index 000000000..c5d52a28f
--- /dev/null
+++ b/src-ui/src/app/components/common/toast/toast.component.spec.ts
@@ -0,0 +1,104 @@
+import {
+ ComponentFixture,
+ discardPeriodicTasks,
+ fakeAsync,
+ flush,
+ TestBed,
+ tick,
+} from '@angular/core/testing'
+
+import { Clipboard } from '@angular/cdk/clipboard'
+import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
+import { ToastComponent } from './toast.component'
+
+const toast1 = {
+ content: 'Error 1 content',
+ delay: 5000,
+ error: 'Error 1 string',
+}
+
+const toast2 = {
+ 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('ToastComponent', () => {
+ let component: ToastComponent
+ let fixture: ComponentFixture
+ let clipboard: Clipboard
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [ToastComponent, NgxBootstrapIconsModule.pick(allIcons)],
+ }).compileComponents()
+
+ fixture = TestBed.createComponent(ToastComponent)
+ clipboard = TestBed.inject(Clipboard)
+ component = fixture.componentInstance
+ })
+
+ it('should create', () => {
+ expect(component).toBeTruthy()
+ })
+
+ it('should countdown toast', fakeAsync(() => {
+ component.toast = toast2
+ fixture.detectChanges()
+ component.onShown(toast2)
+ tick(5000)
+ expect(component.toast.delayRemaining).toEqual(0)
+ flush()
+ discardPeriodicTasks()
+ }))
+
+ it('should show an error if given with toast', fakeAsync(() => {
+ component.toast = toast1
+ fixture.detectChanges()
+
+ expect(fixture.nativeElement.querySelector('details')).not.toBeNull()
+ expect(fixture.nativeElement.textContent).toContain('Error 1 content')
+
+ flush()
+ discardPeriodicTasks()
+ }))
+
+ it('should show error details, support copy', fakeAsync(() => {
+ component.toast = toast2
+ fixture.detectChanges()
+
+ expect(fixture.nativeElement.querySelector('details')).not.toBeNull()
+ expect(fixture.nativeElement.textContent).toContain(
+ 'Error 2 message details'
+ )
+
+ const copySpy = jest.spyOn(clipboard, 'copy')
+ component.copyError(toast2.error)
+ expect(copySpy).toHaveBeenCalled()
+
+ flush()
+ discardPeriodicTasks()
+ }))
+
+ it('should parse error text, add ellipsis', () => {
+ expect(component.getErrorText(toast2.error)).toEqual(
+ 'Error 2 message details'
+ )
+ expect(component.getErrorText({ error: 'Error string no detail' })).toEqual(
+ 'Error string no detail'
+ )
+ expect(component.getErrorText('Error string')).toEqual('')
+ expect(
+ component.getErrorText({ error: { message: 'foo error bar' } })
+ ).toContain('{"message":"foo error bar"}')
+ expect(
+ component.getErrorText({ error: new Array(205).join('a') })
+ ).toContain('...')
+ })
+})
diff --git a/src-ui/src/app/components/common/toast/toast.component.ts b/src-ui/src/app/components/common/toast/toast.component.ts
new file mode 100644
index 000000000..5ebfdbe82
--- /dev/null
+++ b/src-ui/src/app/components/common/toast/toast.component.ts
@@ -0,0 +1,76 @@
+import { Clipboard } from '@angular/cdk/clipboard'
+import { DecimalPipe } from '@angular/common'
+import { Component, EventEmitter, Input, Output } from '@angular/core'
+import {
+ NgbProgressbarModule,
+ NgbToastModule,
+} from '@ng-bootstrap/ng-bootstrap'
+import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
+import { interval, take } from 'rxjs'
+import { Toast } from 'src/app/services/toast.service'
+
+@Component({
+ selector: 'pngx-toast',
+ imports: [
+ DecimalPipe,
+ NgbToastModule,
+ NgbProgressbarModule,
+ NgxBootstrapIconsModule,
+ ],
+ templateUrl: './toast.component.html',
+ styleUrl: './toast.component.scss',
+})
+export class ToastComponent {
+ @Input() toast: Toast
+
+ @Input() autohide: boolean = true
+
+ @Output() hidden: EventEmitter = new EventEmitter()
+
+ @Output() close: EventEmitter = new EventEmitter()
+
+ public copied: boolean = false
+
+ constructor(private clipboard: Clipboard) {}
+
+ onShown(toast: Toast) {
+ if (!this.autohide) return
+
+ const refreshInterval = 150
+ const delay = toast.delay - 500 // for fade animation
+
+ interval(refreshInterval)
+ .pipe(take(Math.round(delay / refreshInterval)))
+ .subscribe((count) => {
+ toast.delayRemaining = Math.max(
+ 0,
+ delay - refreshInterval * (count + 1)
+ )
+ })
+ }
+
+ public isDetailedError(error: any): boolean {
+ return (
+ typeof error === 'object' &&
+ 'status' in error &&
+ 'statusText' in error &&
+ 'url' in error &&
+ 'message' in error &&
+ 'error' in error
+ )
+ }
+
+ public copyError(error: any) {
+ this.clipboard.copy(JSON.stringify(error))
+ this.copied = true
+ setTimeout(() => {
+ this.copied = false
+ }, 3000)
+ }
+
+ getErrorText(error: any) {
+ let text: string = error.error?.detail ?? error.error ?? ''
+ if (typeof text === 'object') text = JSON.stringify(text)
+ return `${text.slice(0, 200)}${text.length > 200 ? '...' : ''}`
+ }
+}
diff --git a/src-ui/src/app/components/common/toasts/toasts.component.html b/src-ui/src/app/components/common/toasts/toasts.component.html
index 36623161b..2178a2023 100644
--- a/src-ui/src/app/components/common/toasts/toasts.component.html
+++ b/src-ui/src/app/components/common/toasts/toasts.component.html
@@ -1,55 +1,3 @@
-@for (toast of toasts; track toast) {
-
-
- {{ toast.delayRemaining / 1000 | number: '1.0-0' }} seconds
-
- @if (!toast.error) {
-
- }
- @if (toast.error) {
-
- }
-
-
{{toast.content}}
- @if (toast.error) {
-
-
- @if (isDetailedError(toast.error)) {
-
- - URL
- - {{ toast.error.url }}
- - Status
- - {{ toast.error.status }} {{ toast.error.statusText }}
- - Error
- - {{ getErrorText(toast.error) }}
-
- }
-
-
-
-
-
-
-
- }
- @if (toast.action) {
-
- }
-
-
-
-
+@for (toast of toasts; track toast.id) {
+
}
diff --git a/src-ui/src/app/components/common/toasts/toasts.component.scss b/src-ui/src/app/components/common/toasts/toasts.component.scss
index 463f96495..e0a069dda 100644
--- a/src-ui/src/app/components/common/toasts/toasts.component.scss
+++ b/src-ui/src/app/components/common/toasts/toasts.component.scss
@@ -1,7 +1,7 @@
:host {
position: fixed;
top: 0;
- right: 0;
+ right: calc(50% - (var(--pngx-toast-max-width) / 2));
margin: 0.3em;
z-index: 1200;
}
@@ -9,24 +9,3 @@
.toast:not(.show) {
display: block; // this corrects an ng-bootstrap bug that prevented animations
}
-
-::ng-deep .toast-body {
- position: relative;
-}
-
-::ng-deep .toast.error {
- border-color: hsla(350, 79%, 40%, 0.4); // bg-danger
-}
-
-::ng-deep .toast.error .toast-body {
- background-color: hsla(350, 79%, 40%, 0.8); // bg-danger
- border-top-left-radius: inherit;
- border-top-right-radius: inherit;
- border-bottom-left-radius: inherit;
- border-bottom-right-radius: inherit;
-}
-
-.progress {
- background-color: var(--pngx-primary);
- opacity: .07;
-}
diff --git a/src-ui/src/app/components/common/toasts/toasts.component.spec.ts b/src-ui/src/app/components/common/toasts/toasts.component.spec.ts
index 449396134..bbea04c9c 100644
--- a/src-ui/src/app/components/common/toasts/toasts.component.spec.ts
+++ b/src-ui/src/app/components/common/toasts/toasts.component.spec.ts
@@ -1,58 +1,33 @@
-import { Clipboard } from '@angular/cdk/clipboard'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
-import {
- ComponentFixture,
- TestBed,
- discardPeriodicTasks,
- fakeAsync,
- flush,
- tick,
-} from '@angular/core/testing'
+import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
-import { of } from 'rxjs'
-import { ToastService } from 'src/app/services/toast.service'
+import { Subject } from 'rxjs'
+import { Toast, ToastService } from 'src/app/services/toast.service'
import { ToastsComponent } from './toasts.component'
-const toasts = [
- {
- content: 'foo bar',
- delay: 5000,
+const toast = {
+ 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' },
},
- {
- content: 'Error 1 content',
- delay: 5000,
- error: 'Error 1 string',
- },
- {
- 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('ToastsComponent', () => {
let component: ToastsComponent
let fixture: ComponentFixture
let toastService: ToastService
- let clipboard: Clipboard
+ let toastSubject: Subject = new Subject()
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [ToastsComponent, NgxBootstrapIconsModule.pick(allIcons)],
providers: [
- {
- provide: ToastService,
- useValue: {
- getToasts: () => of(toasts),
- },
- },
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
@@ -60,95 +35,37 @@ describe('ToastsComponent', () => {
fixture = TestBed.createComponent(ToastsComponent)
toastService = TestBed.inject(ToastService)
- clipboard = TestBed.inject(Clipboard)
+ jest.replaceProperty(toastService, 'showToast', toastSubject)
component = fixture.componentInstance
fixture.detectChanges()
})
- it('should call getToasts and return toasts', fakeAsync(() => {
- const spy = jest.spyOn(toastService, 'getToasts')
+ it('should create', () => {
+ expect(component).toBeTruthy()
+ })
- component.ngOnInit()
- fixture.detectChanges()
+ it('should close toast', () => {
+ component.toasts = [toast]
+ const closeToastSpy = jest.spyOn(toastService, 'closeToast')
+ component.closeToast()
+ expect(component.toasts).toEqual([])
+ expect(closeToastSpy).toHaveBeenCalledWith(toast)
+ })
- expect(spy).toHaveBeenCalled()
- expect(component.toasts).toContainEqual({
- content: 'foo bar',
- delay: 5000,
- })
-
- component.ngOnDestroy()
- flush()
- discardPeriodicTasks()
- }))
-
- it('should show a toast', fakeAsync(() => {
- component.ngOnInit()
- fixture.detectChanges()
-
- expect(fixture.nativeElement.textContent).toContain('foo bar')
-
- component.ngOnDestroy()
- flush()
- discardPeriodicTasks()
- }))
-
- it('should countdown toast', fakeAsync(() => {
- component.ngOnInit()
- fixture.detectChanges()
- component.onShow(toasts[0])
- tick(5000)
- expect(component.toasts[0].delayRemaining).toEqual(0)
- component.ngOnDestroy()
- flush()
- discardPeriodicTasks()
- }))
-
- it('should show an error if given with toast', fakeAsync(() => {
- component.ngOnInit()
- fixture.detectChanges()
-
- expect(fixture.nativeElement.querySelector('details')).not.toBeNull()
- expect(fixture.nativeElement.textContent).toContain('Error 1 content')
-
- component.ngOnDestroy()
- flush()
- discardPeriodicTasks()
- }))
-
- it('should show error details, support copy', fakeAsync(() => {
- component.ngOnInit()
- fixture.detectChanges()
-
- expect(fixture.nativeElement.querySelector('details')).not.toBeNull()
- expect(fixture.nativeElement.textContent).toContain(
- 'Error 2 message details'
+ it('should unsubscribe', () => {
+ const unsubscribeSpy = jest.spyOn(
+ (component as any).subscription,
+ 'unsubscribe'
)
-
- const copySpy = jest.spyOn(clipboard, 'copy')
- component.copyError(toasts[2].error)
- expect(copySpy).toHaveBeenCalled()
-
component.ngOnDestroy()
- flush()
- discardPeriodicTasks()
- }))
+ expect(unsubscribeSpy).toHaveBeenCalled()
+ })
- it('should parse error text, add ellipsis', () => {
- expect(component.getErrorText(toasts[2].error)).toEqual(
- 'Error 2 message details'
- )
- expect(component.getErrorText({ error: 'Error string no detail' })).toEqual(
- 'Error string no detail'
- )
- expect(component.getErrorText('Error string')).toEqual('')
- expect(
- component.getErrorText({ error: { message: 'foo error bar' } })
- ).toContain('{"message":"foo error bar"}')
- expect(
- component.getErrorText({ error: new Array(205).join('a') })
- ).toContain('...')
+ it('should subscribe to toastService', () => {
+ component.ngOnInit()
+ toastSubject.next(toast)
+ expect(component.toasts).toEqual([toast])
})
})
diff --git a/src-ui/src/app/components/common/toasts/toasts.component.ts b/src-ui/src/app/components/common/toasts/toasts.component.ts
index bb791de11..53b6e1895 100644
--- a/src-ui/src/app/components/common/toasts/toasts.component.ts
+++ b/src-ui/src/app/components/common/toasts/toasts.component.ts
@@ -1,92 +1,43 @@
-import { Clipboard } from '@angular/cdk/clipboard'
-import { DecimalPipe } from '@angular/common'
import { Component, OnDestroy, OnInit } from '@angular/core'
import {
+ NgbAccordionModule,
NgbProgressbarModule,
- NgbToastModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
-import { Subscription, interval, take } from 'rxjs'
+import { Subscription } from 'rxjs'
import { Toast, ToastService } from 'src/app/services/toast.service'
+import { ToastComponent } from '../toast/toast.component'
@Component({
selector: 'pngx-toasts',
templateUrl: './toasts.component.html',
styleUrls: ['./toasts.component.scss'],
imports: [
- DecimalPipe,
- NgbToastModule,
+ ToastComponent,
+ NgbAccordionModule,
NgbProgressbarModule,
NgxBootstrapIconsModule,
],
})
export class ToastsComponent implements OnInit, OnDestroy {
- constructor(
- public toastService: ToastService,
- private clipboard: Clipboard
- ) {}
+ constructor(public toastService: ToastService) {}
private subscription: Subscription
- public toasts: Toast[] = []
-
- public copied: boolean = false
-
- public seconds: number = 0
+ public toasts: Toast[] = [] // array to force change detection
ngOnDestroy(): void {
this.subscription?.unsubscribe()
}
ngOnInit(): void {
- this.subscription = this.toastService.getToasts().subscribe((toasts) => {
- this.toasts = toasts
- this.toasts.forEach((t) => {
- if (typeof t.error === 'string') {
- try {
- t.error = JSON.parse(t.error)
- } catch (e) {}
- }
- })
+ this.subscription = this.toastService.showToast.subscribe((toast) => {
+ this.toasts = toast ? [toast] : []
})
}
- onShow(toast: Toast) {
- const refreshInterval = 150
- const delay = toast.delay - 500 // for fade animation
-
- interval(refreshInterval)
- .pipe(take(delay / refreshInterval))
- .subscribe((count) => {
- toast.delayRemaining = Math.max(
- 0,
- delay - refreshInterval * (count + 1)
- )
- })
- }
-
- public isDetailedError(error: any): boolean {
- return (
- typeof error === 'object' &&
- 'status' in error &&
- 'statusText' in error &&
- 'url' in error &&
- 'message' in error &&
- 'error' in error
- )
- }
-
- public copyError(error: any) {
- this.clipboard.copy(JSON.stringify(error))
- this.copied = true
- setTimeout(() => {
- this.copied = false
- }, 3000)
- }
-
- getErrorText(error: any) {
- let text: string = error.error?.detail ?? error.error ?? ''
- if (typeof text === 'object') text = JSON.stringify(text)
- return `${text.slice(0, 200)}${text.length > 200 ? '...' : ''}`
+ closeToast() {
+ this.toastService.closeToast(this.toasts[0])
+ this.toasts = []
}
}
diff --git a/src-ui/src/app/services/toast.service.spec.ts b/src-ui/src/app/services/toast.service.spec.ts
index 274ea9db6..ce50b165e 100644
--- a/src-ui/src/app/services/toast.service.spec.ts
+++ b/src-ui/src/app/services/toast.service.spec.ts
@@ -25,6 +25,33 @@ describe('ToastService', () => {
})
})
+ it('adds a unique id to toast on show', () => {
+ const toast = {
+ title: 'Title',
+ content: 'content',
+ delay: 5000,
+ }
+ toastService.show(toast)
+
+ toastService.getToasts().subscribe((toasts) => {
+ expect(toasts[0].id).toBeDefined()
+ })
+ })
+
+ it('parses error string to object on show', () => {
+ const toast = {
+ title: 'Title',
+ content: 'content',
+ delay: 5000,
+ error: 'Error string',
+ }
+ toastService.show(toast)
+
+ toastService.getToasts().subscribe((toasts) => {
+ expect(toasts[0].error).toEqual('Error string')
+ })
+ })
+
it('creates toasts with defaults on showInfo and showError', () => {
toastService.showInfo('Info toast')
toastService.showError('Error toast')
@@ -54,4 +81,29 @@ describe('ToastService', () => {
expect(toasts).toHaveLength(0)
})
})
+
+ it('clears all toasts on clearToasts', () => {
+ toastService.showInfo('Info toast')
+ toastService.showError('Error toast')
+ toastService.clearToasts()
+
+ toastService.getToasts().subscribe((toasts) => {
+ expect(toasts).toHaveLength(0)
+ })
+ })
+
+ it('suppresses popup toasts if suppressPopupToasts is true', (finish) => {
+ toastService.showToast.subscribe((toast) => {
+ expect(toast).not.toBeNull()
+ })
+ toastService.showInfo('Info toast')
+
+ toastService.showToast.subscribe((toast) => {
+ expect(toast).toBeNull()
+ finish()
+ })
+
+ toastService.suppressPopupToasts = true
+ toastService.showInfo('Info toast')
+ })
})
diff --git a/src-ui/src/app/services/toast.service.ts b/src-ui/src/app/services/toast.service.ts
index 16c534b5c..b917bf94b 100644
--- a/src-ui/src/app/services/toast.service.ts
+++ b/src-ui/src/app/services/toast.service.ts
@@ -1,7 +1,10 @@
import { Injectable } from '@angular/core'
import { Subject } from 'rxjs'
+import { v4 as uuidv4 } from 'uuid'
export interface Toast {
+ id?: string
+
content: string
delay: number
@@ -22,13 +25,32 @@ export interface Toast {
})
export class ToastService {
constructor() {}
+ _suppressPopupToasts: boolean
+
+ set suppressPopupToasts(value: boolean) {
+ this._suppressPopupToasts = value
+ this.showToast.next(null)
+ }
private toasts: Toast[] = []
private toastsSubject: Subject = new Subject()
+ public showToast: Subject = new Subject()
+
show(toast: Toast) {
- this.toasts.push(toast)
+ if (!toast.id) {
+ toast.id = uuidv4()
+ }
+ if (typeof toast.error === 'string') {
+ try {
+ toast.error = JSON.parse(toast.error)
+ } catch (e) {}
+ }
+ this.toasts.unshift(toast)
+ if (!this._suppressPopupToasts) {
+ this.showToast.next(toast)
+ }
this.toastsSubject.next(this.toasts)
}
@@ -46,7 +68,7 @@ export class ToastService {
}
closeToast(toast: Toast) {
- let index = this.toasts.findIndex((t) => t == toast)
+ let index = this.toasts.findIndex((t) => t.id == toast.id)
if (index > -1) {
this.toasts.splice(index, 1)
this.toastsSubject.next(this.toasts)
@@ -56,4 +78,10 @@ export class ToastService {
getToasts() {
return this.toastsSubject
}
+
+ clearToasts() {
+ this.toasts = []
+ this.toastsSubject.next(this.toasts)
+ this.showToast.next(null)
+ }
}
diff --git a/src-ui/src/main.ts b/src-ui/src/main.ts
index 83aa12dc2..484a77c82 100644
--- a/src-ui/src/main.ts
+++ b/src-ui/src/main.ts
@@ -34,6 +34,7 @@ import {
arrowRightShort,
arrowUpRight,
asterisk,
+ bell,
bodyText,
boxArrowUp,
boxArrowUpRight,
@@ -235,6 +236,7 @@ const icons = {
arrowRightShort,
arrowUpRight,
asterisk,
+ bell,
braces,
bodyText,
boxArrowUp,
diff --git a/src-ui/src/styles.scss b/src-ui/src/styles.scss
index 1257798b9..589356566 100644
--- a/src-ui/src/styles.scss
+++ b/src-ui/src/styles.scss
@@ -570,6 +570,10 @@ table.table {
color: var(--bs-body-color);
}
+.toast {
+ --bs-toast-max-width: var(--pngx-toast-max-width);
+}
+
.alert-primary {
--bs-alert-color: var(--bs-primary);
--bs-alert-bg: var(--pngx-primary-faded);
diff --git a/src-ui/src/theme.scss b/src-ui/src/theme.scss
index 9f3c9cbe9..fc8c13d3b 100644
--- a/src-ui/src/theme.scss
+++ b/src-ui/src/theme.scss
@@ -24,6 +24,10 @@
--pngx-bg-alt2: var(--bs-gray-200);
--pngx-bg-disabled: #f7f7f7;
--pngx-focus-alpha: 0.3;
+ --pngx-toast-max-width: 360px;
+ @media screen and (min-width: 1024px) {
+ --pngx-toast-max-width: 450px;
+ }
}
// Dark text colors allow for maintain contrast with theme color changes