mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-08-14 00:26:21 +00:00
Feature: better toast notifications management (#8980)
This commit is contained in:
56
src-ui/src/app/components/common/toast/toast.component.html
Normal file
56
src-ui/src/app/components/common/toast/toast.component.html
Normal file
@@ -0,0 +1,56 @@
|
||||
<ngb-toast
|
||||
[autohide]="autohide"
|
||||
[delay]="toast.delay"
|
||||
[class]="toast.classname"
|
||||
[class.mb-2]="true"
|
||||
(shown)="onShown(toast)"
|
||||
(hidden)="hidden.emit(toast)">
|
||||
@if (autohide) {
|
||||
<ngb-progressbar class="position-absolute h-100 w-100 top-90 start-0 bottom-0 end-0 pe-none" type="dark" [max]="toast.delay" [value]="toast.delayRemaining"></ngb-progressbar>
|
||||
<span class="visually-hidden">{{ toast.delayRemaining / 1000 | number: '1.0-0' }} seconds</span>
|
||||
}
|
||||
<div class="d-flex align-items-top">
|
||||
@if (!toast.error) {
|
||||
<i-bs width="0.9em" height="0.9em" name="info-circle"></i-bs>
|
||||
}
|
||||
@if (toast.error) {
|
||||
<i-bs width="0.9em" height="0.9em" name="exclamation-triangle"></i-bs>
|
||||
}
|
||||
<div>
|
||||
<p class="ms-2 mb-0">{{toast.content}}</p>
|
||||
@if (toast.error) {
|
||||
<details class="ms-2">
|
||||
<div class="mt-2 ms-n4 me-n2 small">
|
||||
@if (isDetailedError(toast.error)) {
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-3 fw-normal text-end">URL</dt>
|
||||
<dd class="col-sm-9">{{ toast.error.url }}</dd>
|
||||
<dt class="col-sm-3 fw-normal text-end" i18n>Status</dt>
|
||||
<dd class="col-sm-9">{{ toast.error.status }} <em>{{ toast.error.statusText }}</em></dd>
|
||||
<dt class="col-sm-3 fw-normal text-end" i18n>Error</dt>
|
||||
<dd class="col-sm-9">{{ getErrorText(toast.error) }}</dd>
|
||||
</dl>
|
||||
}
|
||||
<div class="row">
|
||||
<div class="col offset-sm-3">
|
||||
<button class="btn btn-sm btn-outline-dark" (click)="copyError(toast.error)">
|
||||
@if (!copied) {
|
||||
<i-bs name="clipboard"></i-bs>
|
||||
}
|
||||
@if (copied) {
|
||||
<i-bs name="clipboard-check"></i-bs>
|
||||
}
|
||||
<ng-container i18n>Copy Raw Error</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
}
|
||||
@if (toast.action) {
|
||||
<p class="mb-0 mt-2"><button class="btn btn-sm btn-outline-secondary" (click)="close.emit(toast); toast.action()">{{toast.actionName}}</button></p>
|
||||
}
|
||||
</div>
|
||||
<button type="button" class="btn-close ms-auto flex-shrink-0" data-bs-dismiss="toast" aria-label="Close" (click)="close.emit(toast);"></button>
|
||||
</div>
|
||||
</ngb-toast>
|
20
src-ui/src/app/components/common/toast/toast.component.scss
Normal file
20
src-ui/src/app/components/common/toast/toast.component.scss
Normal file
@@ -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;
|
||||
}
|
104
src-ui/src/app/components/common/toast/toast.component.spec.ts
Normal file
104
src-ui/src/app/components/common/toast/toast.component.spec.ts
Normal file
@@ -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<ToastComponent>
|
||||
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('...')
|
||||
})
|
||||
})
|
76
src-ui/src/app/components/common/toast/toast.component.ts
Normal file
76
src-ui/src/app/components/common/toast/toast.component.ts
Normal file
@@ -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<Toast> = new EventEmitter<Toast>()
|
||||
|
||||
@Output() close: EventEmitter<Toast> = new EventEmitter<Toast>()
|
||||
|
||||
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 ? '...' : ''}`
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user