Very annoying refactor

This commit is contained in:
shamoon
2025-03-04 08:53:11 -08:00
parent 4488da6d3d
commit f28accb28f
81 changed files with 1315 additions and 1151 deletions

View File

@@ -18,9 +18,9 @@ import { NgSelectModule } from '@ng-select/ng-select'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { of } from 'rxjs'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import { NotificationService } from 'src/app/services/notification.service'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { CustomFieldEditDialogComponent } from '../edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
import { SelectComponent } from '../input/select/select.component'
import { CustomFieldsDropdownComponent } from './custom-fields-dropdown.component'
@@ -42,7 +42,7 @@ describe('CustomFieldsDropdownComponent', () => {
let component: CustomFieldsDropdownComponent
let fixture: ComponentFixture<CustomFieldsDropdownComponent>
let customFieldService: CustomFieldsService
let toastService: ToastService
let notificationService: NotificationService
let modalService: NgbModal
let settingsService: SettingsService
@@ -64,7 +64,7 @@ describe('CustomFieldsDropdownComponent', () => {
],
})
customFieldService = TestBed.inject(CustomFieldsService)
toastService = TestBed.inject(ToastService)
notificationService = TestBed.inject(NotificationService)
modalService = TestBed.inject(NgbModal)
jest.spyOn(customFieldService, 'listAll').mockReturnValue(
of({
@@ -113,8 +113,8 @@ describe('CustomFieldsDropdownComponent', () => {
it('should support creating field, show error if necessary, then add', fakeAsync(() => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const notificationErrorSpy = jest.spyOn(notificationService, 'showError')
const notificationInfoSpy = jest.spyOn(notificationService, 'showInfo')
const getFieldsSpy = jest.spyOn(
CustomFieldsDropdownComponent.prototype as any,
'getFields'
@@ -129,13 +129,13 @@ describe('CustomFieldsDropdownComponent', () => {
// fail first
editDialog.failed.emit({ error: 'error creating field' })
expect(toastErrorSpy).toHaveBeenCalled()
expect(notificationErrorSpy).toHaveBeenCalled()
expect(getFieldsSpy).not.toHaveBeenCalled()
// succeed
editDialog.succeeded.emit(fields[0])
tick(100)
expect(toastInfoSpy).toHaveBeenCalled()
expect(notificationInfoSpy).toHaveBeenCalled()
expect(getFieldsSpy).toHaveBeenCalled()
expect(addFieldSpy).toHaveBeenCalled()
}))

View File

@@ -14,13 +14,13 @@ import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { first, takeUntil } from 'rxjs'
import { CustomField, DATA_TYPE_LABELS } from 'src/app/data/custom-field'
import { CustomFieldInstance } from 'src/app/data/custom-field-instance'
import { NotificationService } from 'src/app/services/notification.service'
import {
PermissionAction,
PermissionType,
PermissionsService,
} from 'src/app/services/permissions.service'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { ToastService } from 'src/app/services/toast.service'
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
import { CustomFieldEditDialogComponent } from '../edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
@@ -78,7 +78,7 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio
constructor(
private customFieldsService: CustomFieldsService,
private modalService: NgbModal,
private toastService: ToastService,
private notificationService: NotificationService,
private permissionsService: PermissionsService
) {
super()
@@ -123,7 +123,9 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio
modal.componentInstance.succeeded
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((newField) => {
this.toastService.showInfo($localize`Saved field "${newField.name}".`)
this.notificationService.showInfo(
$localize`Saved field "${newField.name}".`
)
this.customFieldsService.clearCache()
this.getFields()
this.created.emit(newField)
@@ -132,7 +134,7 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio
modal.componentInstance.failed
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((e) => {
this.toastService.showError($localize`Error saving field.`, e)
this.notificationService.showError($localize`Error saving field.`, e)
})
}

View File

@@ -12,11 +12,11 @@ import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { of, throwError } from 'rxjs'
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { NotificationService } from 'src/app/services/notification.service'
import { PermissionsService } from 'src/app/services/permissions.service'
import { GroupService } from 'src/app/services/rest/group.service'
import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { PasswordComponent } from '../../input/password/password.component'
import { PermissionsFormComponent } from '../../input/permissions/permissions-form/permissions-form.component'
import { SelectComponent } from '../../input/select/select.component'
@@ -29,7 +29,7 @@ describe('UserEditDialogComponent', () => {
let component: UserEditDialogComponent
let settingsService: SettingsService
let permissionsService: PermissionsService
let toastService: ToastService
let notificationService: NotificationService
let fixture: ComponentFixture<UserEditDialogComponent>
beforeEach(async () => {
@@ -75,7 +75,7 @@ describe('UserEditDialogComponent', () => {
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser = { id: 99, username: 'user99' }
permissionsService = TestBed.inject(PermissionsService)
toastService = TestBed.inject(ToastService)
notificationService = TestBed.inject(NotificationService)
component = fixture.componentInstance
fixture.detectChanges()
@@ -133,22 +133,22 @@ describe('UserEditDialogComponent', () => {
component['service'] as UserService,
'deactivateTotp'
)
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const notificationErrorSpy = jest.spyOn(notificationService, 'showError')
const notificationInfoSpy = jest.spyOn(notificationService, 'showInfo')
deactivateSpy.mockReturnValueOnce(throwError(() => new Error('error')))
component.deactivateTotp()
expect(deactivateSpy).toHaveBeenCalled()
expect(toastErrorSpy).toHaveBeenCalled()
expect(notificationErrorSpy).toHaveBeenCalled()
deactivateSpy.mockReturnValueOnce(of(false))
component.deactivateTotp()
expect(deactivateSpy).toHaveBeenCalled()
expect(toastErrorSpy).toHaveBeenCalled()
expect(notificationErrorSpy).toHaveBeenCalled()
deactivateSpy.mockReturnValueOnce(of(true))
component.deactivateTotp()
expect(deactivateSpy).toHaveBeenCalled()
expect(toastInfoSpy).toHaveBeenCalled()
expect(notificationInfoSpy).toHaveBeenCalled()
})
it('should check superuser status of current user', () => {

View File

@@ -10,11 +10,11 @@ import { first } from 'rxjs'
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
import { Group } from 'src/app/data/group'
import { User } from 'src/app/data/user'
import { NotificationService } from 'src/app/services/notification.service'
import { PermissionsService } from 'src/app/services/permissions.service'
import { GroupService } from 'src/app/services/rest/group.service'
import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { PasswordComponent } from '../../input/password/password.component'
import { SelectComponent } from '../../input/select/select.component'
import { TextComponent } from '../../input/text/text.component'
@@ -46,7 +46,7 @@ export class UserEditDialogComponent
activeModal: NgbActiveModal,
groupsService: GroupService,
settingsService: SettingsService,
private toastService: ToastService,
private notificationService: NotificationService,
private permissionsService: PermissionsService
) {
super(service, activeModal, service, settingsService)
@@ -128,15 +128,20 @@ export class UserEditDialogComponent
next: (result) => {
this.totpLoading = false
if (result) {
this.toastService.showInfo($localize`Totp deactivated`)
this.notificationService.showInfo($localize`Totp deactivated`)
this.object.is_mfa_enabled = false
} else {
this.toastService.showError($localize`Totp deactivation failed`)
this.notificationService.showError(
$localize`Totp deactivation failed`
)
}
},
error: (e) => {
this.totpLoading = false
this.toastService.showError($localize`Totp deactivation failed`, e)
this.notificationService.showError(
$localize`Totp deactivation failed`,
e
)
},
})
}

View File

@@ -6,9 +6,9 @@ import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { of, throwError } from 'rxjs'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { NotificationService } from 'src/app/services/notification.service'
import { PermissionsService } from 'src/app/services/permissions.service'
import { DocumentService } from 'src/app/services/rest/document.service'
import { ToastService } from 'src/app/services/toast.service'
import { EmailDocumentDialogComponent } from './email-document-dialog.component'
describe('EmailDocumentDialogComponent', () => {
@@ -16,7 +16,7 @@ describe('EmailDocumentDialogComponent', () => {
let fixture: ComponentFixture<EmailDocumentDialogComponent>
let documentService: DocumentService
let permissionsService: PermissionsService
let toastService: ToastService
let notificationService: NotificationService
beforeEach(async () => {
await TestBed.configureTestingModule({
@@ -34,7 +34,7 @@ describe('EmailDocumentDialogComponent', () => {
fixture = TestBed.createComponent(EmailDocumentDialogComponent)
documentService = TestBed.inject(DocumentService)
toastService = TestBed.inject(ToastService)
notificationService = TestBed.inject(NotificationService)
component = fixture.componentInstance
fixture.detectChanges()
})
@@ -47,8 +47,8 @@ describe('EmailDocumentDialogComponent', () => {
})
it('should support sending document via email, showing error if needed', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastSuccessSpy = jest.spyOn(toastService, 'showInfo')
const notificationErrorSpy = jest.spyOn(notificationService, 'showError')
const notificationSuccessSpy = jest.spyOn(notificationService, 'showInfo')
component.emailAddress = 'hello@paperless-ngx.com'
component.emailSubject = 'Hello'
component.emailMessage = 'World'
@@ -56,11 +56,11 @@ describe('EmailDocumentDialogComponent', () => {
.spyOn(documentService, 'emailDocument')
.mockReturnValue(throwError(() => new Error('Unable to email document')))
component.emailDocument()
expect(toastErrorSpy).toHaveBeenCalled()
expect(notificationErrorSpy).toHaveBeenCalled()
jest.spyOn(documentService, 'emailDocument').mockReturnValue(of(true))
component.emailDocument()
expect(toastSuccessSpy).toHaveBeenCalled()
expect(notificationSuccessSpy).toHaveBeenCalled()
})
it('should close the dialog', () => {

View File

@@ -2,8 +2,8 @@ import { Component, Input } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { NotificationService } from 'src/app/services/notification.service'
import { DocumentService } from 'src/app/services/rest/document.service'
import { ToastService } from 'src/app/services/toast.service'
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
@Component({
@@ -40,7 +40,7 @@ export class EmailDocumentDialogComponent extends LoadingComponentWithPermission
constructor(
private activeModal: NgbActiveModal,
private documentService: DocumentService,
private toastService: ToastService
private notificationService: NotificationService
) {
super()
this.loading = false
@@ -62,11 +62,14 @@ export class EmailDocumentDialogComponent extends LoadingComponentWithPermission
this.emailAddress = ''
this.emailSubject = ''
this.emailMessage = ''
this.toastService.showInfo($localize`Email sent`)
this.notificationService.showInfo($localize`Email sent`)
},
error: (e) => {
this.loading = false
this.toastService.showError($localize`Error emailing document`, e)
this.notificationService.showError(
$localize`Error emailing document`,
e
)
},
})
}

View File

@@ -0,0 +1,3 @@
@for (notification of notifications; track notification.id) {
<pngx-notification [notification]="notification" [autohide]="true" (close)="closeNotification()"></pngx-notification>
}

View File

@@ -1,7 +1,7 @@
:host {
position: fixed;
top: 0;
right: calc(50% - (var(--pngx-toast-max-width) / 2));
right: calc(50% - (var(--pngx-notification-max-width) / 2));
margin: 0.3em;
z-index: 1200;
}

View File

@@ -0,0 +1,84 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { Subject } from 'rxjs'
import {
Notification,
NotificationService,
} from 'src/app/services/notification.service'
import { NotificationListComponent } from './notification-list.component'
const notification = {
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('NotificationListComponent', () => {
let component: NotificationListComponent
let fixture: ComponentFixture<NotificationListComponent>
let notificationService: NotificationService
let notificationSubject: Subject<Notification> = new Subject()
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [
NotificationListComponent,
NgxBootstrapIconsModule.pick(allIcons),
],
providers: [
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
}).compileComponents()
fixture = TestBed.createComponent(NotificationListComponent)
notificationService = TestBed.inject(NotificationService)
jest.replaceProperty(
notificationService,
'showNotification',
notificationSubject
)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should create', () => {
expect(component).toBeTruthy()
})
it('should close notification', () => {
component.notifications = [notification]
const closenotificationSpy = jest.spyOn(
notificationService,
'closeNotification'
)
component.closeNotification()
expect(component.notifications).toEqual([])
expect(closenotificationSpy).toHaveBeenCalledWith(notification)
})
it('should unsubscribe', () => {
const unsubscribeSpy = jest.spyOn(
(component as any).subscription,
'unsubscribe'
)
component.ngOnDestroy()
expect(unsubscribeSpy).toHaveBeenCalled()
})
it('should subscribe to notificationService', () => {
component.ngOnInit()
notificationSubject.next(notification)
expect(component.notifications).toEqual([notification])
})
})

View File

@@ -0,0 +1,48 @@
import { Component, OnDestroy, OnInit } from '@angular/core'
import {
NgbAccordionModule,
NgbProgressbarModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Subscription } from 'rxjs'
import {
Notification,
NotificationService,
} from 'src/app/services/notification.service'
import { NotificationComponent } from '../notification/notification.component'
@Component({
selector: 'pngx-notification-list',
templateUrl: './notification-list.component.html',
styleUrls: ['./notification-list.component.scss'],
imports: [
NotificationComponent,
NgbAccordionModule,
NgbProgressbarModule,
NgxBootstrapIconsModule,
],
})
export class NotificationListComponent implements OnInit, OnDestroy {
constructor(public notificationService: NotificationService) {}
private subscription: Subscription
public notifications: Notification[] = [] // array to force change detection
ngOnDestroy(): void {
this.subscription?.unsubscribe()
}
ngOnInit(): void {
this.subscription = this.notificationService.showNotification.subscribe(
(notification) => {
this.notifications = notification ? [notification] : []
}
)
}
closeNotification() {
this.notificationService.closeNotification(this.notifications[0])
this.notifications = []
}
}

View File

@@ -1,39 +1,39 @@
<ngb-toast
[autohide]="autohide"
[delay]="toast.delay"
[class]="toast.classname"
[delay]="notification.delay"
[class]="notification.classname"
[class.mb-2]="true"
(shown)="onShown(toast)"
(hidden)="hidden.emit(toast)">
(shown)="onShown(notification)"
(hidden)="hidden.emit(notification)">
@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>
<ngb-progressbar class="position-absolute h-100 w-100 top-90 start-0 bottom-0 end-0 pe-none" type="dark" [max]="notification.delay" [value]="notification.delayRemaining"></ngb-progressbar>
<span class="visually-hidden">{{ notification.delayRemaining / 1000 | number: '1.0-0' }} seconds</span>
}
<div class="d-flex align-items-top">
@if (!toast.error) {
@if (!notification.error) {
<i-bs width="0.9em" height="0.9em" name="info-circle"></i-bs>
}
@if (toast.error) {
@if (notification.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) {
<p class="ms-2 mb-0">{{notification.content}}</p>
@if (notification.error) {
<details class="ms-2">
<div class="mt-2 ms-n4 me-n2 small">
@if (isDetailedError(toast.error)) {
@if (isDetailedError(notification.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>
<dd class="col-sm-9">{{ notification.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>
<dd class="col-sm-9">{{ notification.error.status }} <em>{{ notification.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>
<dd class="col-sm-9">{{ getErrorText(notification.error) }}</dd>
</dl>
}
<div class="row">
<div class="col offset-sm-3">
<button class="btn btn-sm btn-outline-secondary" (click)="copyError(toast.error)">
<button class="btn btn-sm btn-outline-secondary" (click)="copyError(notification.error)">
@if (!copied) {
<i-bs name="clipboard"></i-bs>&nbsp;
}
@@ -47,10 +47,10 @@
</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>
@if (notification.action) {
<p class="mb-0 mt-2"><button class="btn btn-sm btn-outline-secondary" (click)="close.emit(notification); notification.action()">{{notification.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>
<button type="button" class="btn-close ms-auto flex-shrink-0" data-bs-dismiss="notification" aria-label="Close" (click)="close.emit(notification);"></button>
</div>
</ngb-toast>

View File

@@ -9,15 +9,15 @@ import {
import { Clipboard } from '@angular/cdk/clipboard'
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { ToastComponent } from './toast.component'
import { NotificationComponent } from './notification.component'
const toast1 = {
const notification1 = {
content: 'Error 1 content',
delay: 5000,
error: 'Error 1 string',
}
const toast2 = {
const notification2 = {
content: 'Error 2 content',
delay: 5000,
error: {
@@ -29,17 +29,17 @@ const toast2 = {
},
}
describe('ToastComponent', () => {
let component: ToastComponent
let fixture: ComponentFixture<ToastComponent>
describe('NotificationComponent', () => {
let component: NotificationComponent
let fixture: ComponentFixture<NotificationComponent>
let clipboard: Clipboard
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ToastComponent, NgxBootstrapIconsModule.pick(allIcons)],
imports: [NotificationComponent, NgxBootstrapIconsModule.pick(allIcons)],
}).compileComponents()
fixture = TestBed.createComponent(ToastComponent)
fixture = TestBed.createComponent(NotificationComponent)
clipboard = TestBed.inject(Clipboard)
component = fixture.componentInstance
})
@@ -48,18 +48,18 @@ describe('ToastComponent', () => {
expect(component).toBeTruthy()
})
it('should countdown toast', fakeAsync(() => {
component.toast = toast2
it('should countdown notification', fakeAsync(() => {
component.notification = notification2
fixture.detectChanges()
component.onShown(toast2)
component.onShown(notification2)
tick(5000)
expect(component.toast.delayRemaining).toEqual(0)
expect(component.notification.delayRemaining).toEqual(0)
flush()
discardPeriodicTasks()
}))
it('should show an error if given with toast', fakeAsync(() => {
component.toast = toast1
it('should show an error if given with notification', fakeAsync(() => {
component.notification = notification1
fixture.detectChanges()
expect(fixture.nativeElement.querySelector('details')).not.toBeNull()
@@ -70,7 +70,7 @@ describe('ToastComponent', () => {
}))
it('should show error details, support copy', fakeAsync(() => {
component.toast = toast2
component.notification = notification2
fixture.detectChanges()
expect(fixture.nativeElement.querySelector('details')).not.toBeNull()
@@ -79,7 +79,7 @@ describe('ToastComponent', () => {
)
const copySpy = jest.spyOn(clipboard, 'copy')
component.copyError(toast2.error)
component.copyError(notification2.error)
expect(copySpy).toHaveBeenCalled()
flush()
@@ -87,7 +87,7 @@ describe('ToastComponent', () => {
}))
it('should parse error text, add ellipsis', () => {
expect(component.getErrorText(toast2.error)).toEqual(
expect(component.getErrorText(notification2.error)).toEqual(
'Error 2 message details'
)
expect(component.getErrorText({ error: 'Error string no detail' })).toEqual(

View File

@@ -7,42 +7,43 @@ import {
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { interval, take } from 'rxjs'
import { Toast } from 'src/app/services/toast.service'
import { Notification } from 'src/app/services/notification.service'
@Component({
selector: 'pngx-toast',
selector: 'pngx-notification',
imports: [
DecimalPipe,
NgbToastModule,
NgbProgressbarModule,
NgxBootstrapIconsModule,
],
templateUrl: './toast.component.html',
styleUrl: './toast.component.scss',
templateUrl: './notification.component.html',
styleUrl: './notification.component.scss',
})
export class ToastComponent {
@Input() toast: Toast
export class NotificationComponent {
@Input() notification: Notification
@Input() autohide: boolean = true
@Output() hidden: EventEmitter<Toast> = new EventEmitter<Toast>()
@Output() hidden: EventEmitter<Notification> =
new EventEmitter<Notification>()
@Output() close: EventEmitter<Toast> = new EventEmitter<Toast>()
@Output() close: EventEmitter<Notification> = new EventEmitter<Notification>()
public copied: boolean = false
constructor(private clipboard: Clipboard) {}
onShown(toast: Toast) {
onShown(notification: Notification) {
if (!this.autohide) return
const refreshInterval = 150
const delay = toast.delay - 500 // for fade animation
const delay = notification.delay - 500 // for fade animation
interval(refreshInterval)
.pipe(take(Math.round(delay / refreshInterval)))
.subscribe((count) => {
toast.delayRemaining = Math.max(
notification.delayRemaining = Math.max(
0,
delay - refreshInterval * (count + 1)
)

View File

@@ -16,8 +16,8 @@ import {
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { of, throwError } from 'rxjs'
import { NotificationService } from 'src/app/services/notification.service'
import { ProfileService } from 'src/app/services/profile.service'
import { ToastService } from 'src/app/services/toast.service'
import { ConfirmButtonComponent } from '../confirm-button/confirm-button.component'
import { PasswordComponent } from '../input/password/password.component'
import { TextComponent } from '../input/text/text.component'
@@ -44,7 +44,7 @@ describe('ProfileEditDialogComponent', () => {
let component: ProfileEditDialogComponent
let fixture: ComponentFixture<ProfileEditDialogComponent>
let profileService: ProfileService
let toastService: ToastService
let notificationService: NotificationService
let clipboard: Clipboard
beforeEach(() => {
@@ -64,7 +64,7 @@ describe('ProfileEditDialogComponent', () => {
providers: [NgbActiveModal, provideHttpClient(withInterceptorsFromDi())],
})
profileService = TestBed.inject(ProfileService)
toastService = TestBed.inject(ToastService)
notificationService = TestBed.inject(NotificationService)
clipboard = TestBed.inject(Clipboard)
fixture = TestBed.createComponent(ProfileEditDialogComponent)
component = fixture.componentInstance
@@ -94,13 +94,13 @@ describe('ProfileEditDialogComponent', () => {
auth_token: profile.auth_token,
}
const updateSpy = jest.spyOn(profileService, 'update')
const errorSpy = jest.spyOn(toastService, 'showError')
const errorSpy = jest.spyOn(notificationService, 'showError')
updateSpy.mockReturnValueOnce(throwError(() => new Error('failed to save')))
component.save()
expect(errorSpy).toHaveBeenCalled()
updateSpy.mockClear()
const infoSpy = jest.spyOn(toastService, 'showInfo')
const infoSpy = jest.spyOn(notificationService, 'showInfo')
component.form.patchValue(newProfile)
updateSpy.mockReturnValueOnce(of(newProfile))
component.save()
@@ -239,7 +239,7 @@ describe('ProfileEditDialogComponent', () => {
getSpy.mockReturnValue(of(profile))
const generateSpy = jest.spyOn(profileService, 'generateAuthToken')
const errorSpy = jest.spyOn(toastService, 'showError')
const errorSpy = jest.spyOn(notificationService, 'showError')
generateSpy.mockReturnValueOnce(
throwError(() => new Error('failed to generate'))
)
@@ -275,7 +275,7 @@ describe('ProfileEditDialogComponent', () => {
getSpy.mockImplementation(() => of(profile))
component.ngOnInit()
const errorSpy = jest.spyOn(toastService, 'showError')
const errorSpy = jest.spyOn(notificationService, 'showError')
expect(component.socialAccounts).toContainEqual(socialAccount)
@@ -300,13 +300,13 @@ describe('ProfileEditDialogComponent', () => {
secret: 'secret',
}
const getSpy = jest.spyOn(profileService, 'getTotpSettings')
const toastSpy = jest.spyOn(toastService, 'showError')
const notificationSpy = jest.spyOn(notificationService, 'showError')
getSpy.mockReturnValueOnce(
throwError(() => new Error('failed to get settings'))
)
component.gettotpSettings()
expect(getSpy).toHaveBeenCalled()
expect(toastSpy).toHaveBeenCalled()
expect(notificationSpy).toHaveBeenCalled()
getSpy.mockReturnValue(of(settings))
component.gettotpSettings()
@@ -316,8 +316,8 @@ describe('ProfileEditDialogComponent', () => {
it('should activate totp', () => {
const activateSpy = jest.spyOn(profileService, 'activateTotp')
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const notificationErrorSpy = jest.spyOn(notificationService, 'showError')
const notificationInfoSpy = jest.spyOn(notificationService, 'showInfo')
const error = new Error('failed to activate totp')
activateSpy.mockReturnValueOnce(throwError(() => error))
component.totpSettings = {
@@ -331,38 +331,44 @@ describe('ProfileEditDialogComponent', () => {
component.totpSettings.secret,
component.form.get('totp_code').value
)
expect(toastErrorSpy).toHaveBeenCalled()
expect(notificationErrorSpy).toHaveBeenCalled()
activateSpy.mockReturnValueOnce(of({ success: false, recovery_codes: [] }))
component.activateTotp()
expect(toastErrorSpy).toHaveBeenCalledWith('Error activating TOTP', error)
expect(notificationErrorSpy).toHaveBeenCalledWith(
'Error activating TOTP',
error
)
activateSpy.mockReturnValueOnce(
of({ success: true, recovery_codes: ['1', '2', '3'] })
)
component.activateTotp()
expect(toastInfoSpy).toHaveBeenCalled()
expect(notificationInfoSpy).toHaveBeenCalled()
expect(component.isTotpEnabled).toBeTruthy()
expect(component.recoveryCodes).toEqual(['1', '2', '3'])
})
it('should deactivate totp', () => {
const deactivateSpy = jest.spyOn(profileService, 'deactivateTotp')
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const notificationErrorSpy = jest.spyOn(notificationService, 'showError')
const notificationInfoSpy = jest.spyOn(notificationService, 'showInfo')
const error = new Error('failed to deactivate totp')
deactivateSpy.mockReturnValueOnce(throwError(() => error))
component.deactivateTotp()
expect(deactivateSpy).toHaveBeenCalled()
expect(toastErrorSpy).toHaveBeenCalled()
expect(notificationErrorSpy).toHaveBeenCalled()
deactivateSpy.mockReturnValueOnce(of(false))
component.deactivateTotp()
expect(toastErrorSpy).toHaveBeenCalledWith('Error deactivating TOTP', error)
expect(notificationErrorSpy).toHaveBeenCalledWith(
'Error deactivating TOTP',
error
)
deactivateSpy.mockReturnValueOnce(of(true))
component.deactivateTotp()
expect(toastInfoSpy).toHaveBeenCalled()
expect(notificationInfoSpy).toHaveBeenCalled()
expect(component.isTotpEnabled).toBeFalsy()
})

View File

@@ -19,8 +19,8 @@ import {
TotpSettings,
} from 'src/app/data/user-profile'
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
import { NotificationService } from 'src/app/services/notification.service'
import { ProfileService } from 'src/app/services/profile.service'
import { ToastService } from 'src/app/services/toast.service'
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
import { ConfirmButtonComponent } from '../confirm-button/confirm-button.component'
import { PasswordComponent } from '../input/password/password.component'
@@ -86,7 +86,7 @@ export class ProfileEditDialogComponent
constructor(
private profileService: ProfileService,
public activeModal: NgbActiveModal,
private toastService: ToastService,
private notificationService: NotificationService,
private clipboard: Clipboard
) {
super()
@@ -192,9 +192,11 @@ export class ProfileEditDialogComponent
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({
next: () => {
this.toastService.showInfo($localize`Profile updated successfully`)
this.notificationService.showInfo(
$localize`Profile updated successfully`
)
if (passwordChanged) {
this.toastService.showInfo(
this.notificationService.showInfo(
$localize`Password has been changed, you will be logged out momentarily.`
)
setTimeout(() => {
@@ -204,7 +206,10 @@ export class ProfileEditDialogComponent
this.activeModal.close()
},
error: (error) => {
this.toastService.showError($localize`Error saving profile`, error)
this.notificationService.showError(
$localize`Error saving profile`,
error
)
this.networkActive = false
},
})
@@ -220,7 +225,7 @@ export class ProfileEditDialogComponent
this.form.patchValue({ auth_token: token })
},
error: (error) => {
this.toastService.showError(
this.notificationService.showError(
$localize`Error generating auth token`,
error
)
@@ -245,7 +250,7 @@ export class ProfileEditDialogComponent
this.socialAccounts = this.socialAccounts.filter((a) => a.id != id)
},
error: (error) => {
this.toastService.showError(
this.notificationService.showError(
$localize`Error disconnecting social account`,
error
)
@@ -264,7 +269,7 @@ export class ProfileEditDialogComponent
this.totpSettings = totpSettings
},
error: (error) => {
this.toastService.showError(
this.notificationService.showError(
$localize`Error fetching TOTP settings`,
error
)
@@ -286,15 +291,20 @@ export class ProfileEditDialogComponent
this.recoveryCodes = activationResponse.recovery_codes
this.form.get('totp_code').enable()
if (activationResponse.success) {
this.toastService.showInfo($localize`TOTP activated successfully`)
this.notificationService.showInfo(
$localize`TOTP activated successfully`
)
} else {
this.toastService.showError($localize`Error activating TOTP`)
this.notificationService.showError($localize`Error activating TOTP`)
}
},
error: (error) => {
this.totpLoading = false
this.form.get('totp_code').enable()
this.toastService.showError($localize`Error activating TOTP`, error)
this.notificationService.showError(
$localize`Error activating TOTP`,
error
)
},
})
}
@@ -310,14 +320,21 @@ export class ProfileEditDialogComponent
this.isTotpEnabled = !success
this.recoveryCodes = null
if (success) {
this.toastService.showInfo($localize`TOTP deactivated successfully`)
this.notificationService.showInfo(
$localize`TOTP deactivated successfully`
)
} else {
this.toastService.showError($localize`Error deactivating TOTP`)
this.notificationService.showError(
$localize`Error deactivating TOTP`
)
}
},
error: (error) => {
this.totpLoading = false
this.toastService.showError($localize`Error deactivating TOTP`, error)
this.notificationService.showError(
$localize`Error deactivating TOTP`,
error
)
},
})
}

View File

@@ -15,8 +15,8 @@ import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { of, throwError } from 'rxjs'
import { FileVersion, ShareLink } from 'src/app/data/share-link'
import { NotificationService } from 'src/app/services/notification.service'
import { ShareLinkService } from 'src/app/services/rest/share-link.service'
import { ToastService } from 'src/app/services/toast.service'
import { environment } from 'src/environments/environment'
import { ShareLinksDialogComponent } from './share-links-dialog.component'
@@ -24,7 +24,7 @@ describe('ShareLinksDialogComponent', () => {
let component: ShareLinksDialogComponent
let fixture: ComponentFixture<ShareLinksDialogComponent>
let shareLinkService: ShareLinkService
let toastService: ToastService
let notificationService: NotificationService
let httpController: HttpTestingController
let clipboard: Clipboard
@@ -43,7 +43,7 @@ describe('ShareLinksDialogComponent', () => {
fixture = TestBed.createComponent(ShareLinksDialogComponent)
shareLinkService = TestBed.inject(ShareLinkService)
toastService = TestBed.inject(ToastService)
notificationService = TestBed.inject(NotificationService)
httpController = TestBed.inject(HttpTestingController)
clipboard = TestBed.inject(Clipboard)
@@ -89,7 +89,7 @@ describe('ShareLinksDialogComponent', () => {
})
it('should show error on refresh if needed', () => {
const toastSpy = jest.spyOn(toastService, 'showError')
const notificationSpy = jest.spyOn(notificationService, 'showError')
jest
.spyOn(shareLinkService, 'getLinksForDocument')
.mockReturnValueOnce(throwError(() => new Error('Unable to get links')))
@@ -97,7 +97,7 @@ describe('ShareLinksDialogComponent', () => {
component.ngOnInit()
fixture.detectChanges()
expect(toastSpy).toHaveBeenCalled()
expect(notificationSpy).toHaveBeenCalled()
})
it('should support link creation then refresh & copy url', fakeAsync(() => {
@@ -138,7 +138,7 @@ describe('ShareLinksDialogComponent', () => {
const expiration = new Date()
expiration.setDate(expiration.getDate() + 7)
const toastSpy = jest.spyOn(toastService, 'showError')
const notificationSpy = jest.spyOn(notificationService, 'showError')
component.createLink()
@@ -150,7 +150,7 @@ describe('ShareLinksDialogComponent', () => {
)
fixture.detectChanges()
expect(toastSpy).toHaveBeenCalled()
expect(notificationSpy).toHaveBeenCalled()
})
it('should support delete links & refresh', () => {
@@ -165,13 +165,13 @@ describe('ShareLinksDialogComponent', () => {
})
it('should show error on delete if needed', () => {
const toastSpy = jest.spyOn(toastService, 'showError')
const notificationSpy = jest.spyOn(notificationService, 'showError')
jest
.spyOn(shareLinkService, 'delete')
.mockReturnValueOnce(throwError(() => new Error('Unable to delete link')))
component.delete(null)
fixture.detectChanges()
expect(toastSpy).toHaveBeenCalled()
expect(notificationSpy).toHaveBeenCalled()
})
it('should format days remaining', () => {

View File

@@ -5,8 +5,8 @@ import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { first } from 'rxjs'
import { FileVersion, ShareLink } from 'src/app/data/share-link'
import { NotificationService } from 'src/app/services/notification.service'
import { ShareLinkService } from 'src/app/services/rest/share-link.service'
import { ToastService } from 'src/app/services/toast.service'
import { environment } from 'src/environments/environment'
@Component({
@@ -61,7 +61,7 @@ export class ShareLinksDialogComponent implements OnInit {
constructor(
private activeModal: NgbActiveModal,
private shareLinkService: ShareLinkService,
private toastService: ToastService,
private notificationService: NotificationService,
private clipboard: Clipboard
) {}
@@ -81,7 +81,7 @@ export class ShareLinksDialogComponent implements OnInit {
this.shareLinks = results
},
error: (e) => {
this.toastService.showError(
this.notificationService.showError(
$localize`Error retrieving links`,
10000,
e
@@ -130,7 +130,11 @@ export class ShareLinksDialogComponent implements OnInit {
this.refresh()
},
error: (e) => {
this.toastService.showError($localize`Error deleting link`, 10000, e)
this.notificationService.showError(
$localize`Error deleting link`,
10000,
e
)
},
})
}
@@ -158,7 +162,11 @@ export class ShareLinksDialogComponent implements OnInit {
},
error: (e) => {
this.loading = false
this.toastService.showError($localize`Error creating link`, 10000, e)
this.notificationService.showError(
$localize`Error creating link`,
10000,
e
)
},
})
}

View File

@@ -16,9 +16,9 @@ import {
SystemStatus,
SystemStatusItemStatus,
} from 'src/app/data/system-status'
import { NotificationService } from 'src/app/services/notification.service'
import { SystemStatusService } from 'src/app/services/system-status.service'
import { TasksService } from 'src/app/services/tasks.service'
import { ToastService } from 'src/app/services/toast.service'
import { SystemStatusDialogComponent } from './system-status-dialog.component'
const status: SystemStatus = {
@@ -61,7 +61,7 @@ describe('SystemStatusDialogComponent', () => {
let clipboard: Clipboard
let tasksService: TasksService
let systemStatusService: SystemStatusService
let toastService: ToastService
let notificationService: NotificationService
beforeEach(async () => {
await TestBed.configureTestingModule({
@@ -82,7 +82,7 @@ describe('SystemStatusDialogComponent', () => {
clipboard = TestBed.inject(Clipboard)
tasksService = TestBed.inject(TasksService)
systemStatusService = TestBed.inject(SystemStatusService)
toastService = TestBed.inject(ToastService)
notificationService = TestBed.inject(NotificationService)
fixture.detectChanges()
})
@@ -116,9 +116,9 @@ describe('SystemStatusDialogComponent', () => {
expect(component.isRunning(PaperlessTaskName.SanityCheck)).toBeFalsy()
})
it('should support running tasks, refresh status and show toasts', () => {
const toastSpy = jest.spyOn(toastService, 'showInfo')
const toastErrorSpy = jest.spyOn(toastService, 'showError')
it('should support running tasks, refresh status and show notifications', () => {
const notificationSpy = jest.spyOn(notificationService, 'showInfo')
const notificationErrorSpy = jest.spyOn(notificationService, 'showError')
const getStatusSpy = jest.spyOn(systemStatusService, 'get')
const runSpy = jest.spyOn(tasksService, 'run')
@@ -126,7 +126,7 @@ describe('SystemStatusDialogComponent', () => {
runSpy.mockReturnValue(throwError(() => new Error('error')))
component.runTask(PaperlessTaskName.IndexOptimize)
expect(runSpy).toHaveBeenCalledWith(PaperlessTaskName.IndexOptimize)
expect(toastErrorSpy).toHaveBeenCalledWith(
expect(notificationErrorSpy).toHaveBeenCalledWith(
`Failed to start task ${PaperlessTaskName.IndexOptimize}, see the logs for more details`,
expect.any(Error)
)
@@ -138,7 +138,7 @@ describe('SystemStatusDialogComponent', () => {
expect(runSpy).toHaveBeenCalledWith(PaperlessTaskName.IndexOptimize)
expect(getStatusSpy).toHaveBeenCalled()
expect(toastSpy).toHaveBeenCalledWith(
expect(notificationSpy).toHaveBeenCalledWith(
`Task ${PaperlessTaskName.IndexOptimize} started`
)
})

View File

@@ -14,10 +14,10 @@ import {
} from 'src/app/data/system-status'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { FileSizePipe } from 'src/app/pipes/file-size.pipe'
import { NotificationService } from 'src/app/services/notification.service'
import { PermissionsService } from 'src/app/services/permissions.service'
import { SystemStatusService } from 'src/app/services/system-status.service'
import { TasksService } from 'src/app/services/tasks.service'
import { ToastService } from 'src/app/services/toast.service'
@Component({
selector: 'pngx-system-status-dialog',
@@ -51,7 +51,7 @@ export class SystemStatusDialogComponent {
private clipboard: Clipboard,
private systemStatusService: SystemStatusService,
private tasksService: TasksService,
private toastService: ToastService,
private notificationService: NotificationService,
private permissionsService: PermissionsService
) {}
@@ -79,7 +79,7 @@ export class SystemStatusDialogComponent {
public runTask(taskName: PaperlessTaskName) {
this.runningTasks.add(taskName)
this.toastService.showInfo(`Task ${taskName} started`)
this.notificationService.showInfo(`Task ${taskName} started`)
this.tasksService.run(taskName).subscribe({
next: () => {
this.runningTasks.delete(taskName)
@@ -91,7 +91,7 @@ export class SystemStatusDialogComponent {
},
error: (err) => {
this.runningTasks.delete(taskName)
this.toastService.showError(
this.notificationService.showError(
`Failed to start task ${taskName}, see the logs for more details`,
err
)

View File

@@ -1,3 +0,0 @@
@for (toast of toasts; track toast.id) {
<pngx-toast [toast]="toast" [autohide]="true" (close)="closeToast()"></pngx-toast>
}

View File

@@ -1,71 +0,0 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } 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 { ToastsComponent } from './toasts.component'
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' },
},
}
describe('ToastsComponent', () => {
let component: ToastsComponent
let fixture: ComponentFixture<ToastsComponent>
let toastService: ToastService
let toastSubject: Subject<Toast> = new Subject()
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [ToastsComponent, NgxBootstrapIconsModule.pick(allIcons)],
providers: [
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
}).compileComponents()
fixture = TestBed.createComponent(ToastsComponent)
toastService = TestBed.inject(ToastService)
jest.replaceProperty(toastService, 'showToast', toastSubject)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should create', () => {
expect(component).toBeTruthy()
})
it('should close toast', () => {
component.toasts = [toast]
const closeToastSpy = jest.spyOn(toastService, 'closeToast')
component.closeToast()
expect(component.toasts).toEqual([])
expect(closeToastSpy).toHaveBeenCalledWith(toast)
})
it('should unsubscribe', () => {
const unsubscribeSpy = jest.spyOn(
(component as any).subscription,
'unsubscribe'
)
component.ngOnDestroy()
expect(unsubscribeSpy).toHaveBeenCalled()
})
it('should subscribe to toastService', () => {
component.ngOnInit()
toastSubject.next(toast)
expect(component.toasts).toEqual([toast])
})
})

View File

@@ -1,43 +0,0 @@
import { Component, OnDestroy, OnInit } from '@angular/core'
import {
NgbAccordionModule,
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 '../toast/toast.component'
@Component({
selector: 'pngx-toasts',
templateUrl: './toasts.component.html',
styleUrls: ['./toasts.component.scss'],
imports: [
ToastComponent,
NgbAccordionModule,
NgbProgressbarModule,
NgxBootstrapIconsModule,
],
})
export class ToastsComponent implements OnInit, OnDestroy {
constructor(public toastService: ToastService) {}
private subscription: Subscription
public toasts: Toast[] = [] // array to force change detection
ngOnDestroy(): void {
this.subscription?.unsubscribe()
}
ngOnInit(): void {
this.subscription = this.toastService.showToast.subscribe((toast) => {
this.toasts = toast ? [toast] : []
})
}
closeToast() {
this.toastService.closeToast(this.toasts[0])
this.toasts = []
}
}