diff --git a/src-ui/src/app/app.component.spec.ts b/src-ui/src/app/app.component.spec.ts index 721aa37b3..d93534d80 100644 --- a/src-ui/src/app/app.component.spec.ts +++ b/src-ui/src/app/app.component.spec.ts @@ -19,6 +19,7 @@ import { PermissionsGuard } from './guards/permissions.guard' import { HotKeyService } from './services/hot-key.service' import { PermissionsService } from './services/permissions.service' import { SettingsService } from './services/settings.service' +import { TasksService } from './services/tasks.service' import { Toast, ToastService } from './services/toast.service' import { FileStatus, @@ -34,6 +35,7 @@ describe('AppComponent', () => { let toastService: ToastService let router: Router let settingsService: SettingsService + let tasksService: TasksService let hotKeyService: HotKeyService beforeEach(async () => { @@ -60,6 +62,7 @@ describe('AppComponent', () => { websocketStatusService = TestBed.inject(WebsocketStatusService) permissionsService = TestBed.inject(PermissionsService) settingsService = TestBed.inject(SettingsService) + tasksService = TestBed.inject(TasksService) toastService = TestBed.inject(ToastService) router = TestBed.inject(Router) hotKeyService = TestBed.inject(HotKeyService) @@ -117,30 +120,33 @@ describe('AppComponent', () => { expect(toast.action).toBeUndefined() }) - it('should display toast on document added', () => { - jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) + it('should only reload tasks when a document is detected', () => { const toastSpy = jest.spyOn(toastService, 'show') + const reloadSpy = jest.spyOn(tasksService, 'reload') const fileStatusSubject = new Subject() jest .spyOn(websocketStatusService, 'onDocumentDetected') .mockReturnValue(fileStatusSubject) component.ngOnInit() fileStatusSubject.next(new FileStatus()) - expect(toastSpy).toHaveBeenCalled() + expect(reloadSpy).toHaveBeenCalled() + expect(toastSpy).not.toHaveBeenCalled() }) - it('should suppress dashboard notifications if set', () => { + it('should add consumed documents to the bell without a popup', () => { jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) - jest.spyOn(settingsService, 'get').mockReturnValue(true) - jest.spyOn(router, 'url', 'get').mockReturnValue('/dashboard') - const toastSpy = jest.spyOn(toastService, 'show') + const popupSpy = jest.fn() + toastService.showToast.subscribe(popupSpy) + let toasts: Toast[] = [] + toastService.getToasts().subscribe((t) => (toasts = [...t])) const fileStatusSubject = new Subject() jest - .spyOn(websocketStatusService, 'onDocumentDetected') + .spyOn(websocketStatusService, 'onDocumentConsumptionFinished') .mockReturnValue(fileStatusSubject) component.ngOnInit() fileStatusSubject.next(new FileStatus()) - expect(toastSpy).not.toHaveBeenCalled() + expect(toasts).toHaveLength(1) + expect(popupSpy).not.toHaveBeenCalled() }) it('should display toast on document failed', () => { diff --git a/src-ui/src/app/app.component.ts b/src-ui/src/app/app.component.ts index 6af7edf85..b3a004f28 100644 --- a/src-ui/src/app/app.component.ts +++ b/src-ui/src/app/app.component.ts @@ -14,7 +14,7 @@ import { } from './services/permissions.service' import { SettingsService } from './services/settings.service' import { TasksService } from './services/tasks.service' -import { ToastService } from './services/toast.service' +import { ToastLevel, ToastService } from './services/toast.service' import { WebsocketStatusService } from './services/websocket-status.service' @Component({ @@ -61,18 +61,6 @@ export class AppComponent implements OnInit, OnDestroy { } } - private showNotification(key) { - if ( - this.router.url == '/dashboard' && - this.settings.get( - SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD - ) - ) { - return false - } - return this.settings.get(key) - } - ngOnInit(): void { this.websocketStatusService.connect() @@ -80,29 +68,22 @@ export class AppComponent implements OnInit, OnDestroy { .onDocumentConsumptionFinished() .subscribe((status) => { this.tasksService.reload() - if ( - this.showNotification(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS) - ) { - if ( - this.permissionsService.currentUserCan( - PermissionAction.View, - PermissionType.Document - ) - ) { - this.toastService.show({ - content: $localize`Document ${status.filename} was added to Paperless-ngx.`, - delay: 10000, - actionName: $localize`Open document`, - action: () => { - this.router.navigate(['documents', status.documentId]) - }, - }) - } else { - this.toastService.show({ - content: $localize`Document ${status.filename} was added to Paperless-ngx.`, - delay: 10000, - }) - } + if (this.settings.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS)) { + const canView = this.permissionsService.currentUserCan( + PermissionAction.View, + PermissionType.Document + ) + // the upload status tray already shows this, so only keep a record + this.toastService.show({ + content: $localize`Document ${status.filename} was added to Paperless-ngx.`, + delay: 10000, + level: ToastLevel.Success, + silent: true, + actionName: canView ? $localize`Open document` : undefined, + action: canView + ? () => this.router.navigate(['documents', status.documentId]) + : undefined, + }) } }) @@ -110,9 +91,7 @@ export class AppComponent implements OnInit, OnDestroy { .onDocumentConsumptionFailed() .subscribe((status) => { this.tasksService.reload() - if ( - this.showNotification(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED) - ) { + if (this.settings.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED)) { this.toastService.showError( $localize`Could not add ${status.filename}\: ${status.message}` ) @@ -121,18 +100,8 @@ export class AppComponent implements OnInit, OnDestroy { this.newDocumentSubscription = this.websocketStatusService .onDocumentDetected() - .subscribe((status) => { + .subscribe(() => { this.tasksService.reload() - if ( - this.showNotification( - SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT - ) - ) { - this.toastService.show({ - content: $localize`Document ${status.filename} is being processed by Paperless-ngx.`, - delay: 5000, - }) - } }) this.hotKeyService diff --git a/src-ui/src/app/components/admin/config/config.component.ts b/src-ui/src/app/components/admin/config/config.component.ts index 570eb7d55..6894a50be 100644 --- a/src-ui/src/app/components/admin/config/config.component.ts +++ b/src-ui/src/app/components/admin/config/config.component.ts @@ -190,7 +190,7 @@ export class ConfigComponent this.initialize(config) this.store.next(config) this.settingsService.initializeSettings().subscribe() - this.toastService.showInfo($localize`Configuration updated`) + this.toastService.showSuccess($localize`Configuration updated`) }, error: (e) => { this.loading.set(false) @@ -217,7 +217,7 @@ export class ConfigComponent this.initialize(config) this.store.next(config) this.settingsService.initializeSettings().subscribe() - this.toastService.showInfo($localize`File successfully updated`) + this.toastService.showSuccess($localize`File successfully updated`) }, error: (e) => { this.loading.set(false) diff --git a/src-ui/src/app/components/admin/settings/settings.component.html b/src-ui/src/app/components/admin/settings/settings.component.html index 4ff3e671d..89ea62c3e 100644 --- a/src-ui/src/app/components/admin/settings/settings.component.html +++ b/src-ui/src/app/components/admin/settings/settings.component.html @@ -425,10 +425,8 @@
- - + -
diff --git a/src-ui/src/app/components/admin/settings/settings.component.spec.ts b/src-ui/src/app/components/admin/settings/settings.component.spec.ts index 1389a290d..d64ea2a9b 100644 --- a/src-ui/src/app/components/admin/settings/settings.component.spec.ts +++ b/src-ui/src/app/components/admin/settings/settings.component.spec.ts @@ -307,7 +307,7 @@ describe('SettingsComponent', () => { expect(toastErrorSpy).toHaveBeenCalled() expect(storeSpy).toHaveBeenCalled() expect(appearanceSettingsSpy).not.toHaveBeenCalled() - expect(setSpy).toHaveBeenCalledTimes(35) + expect(setSpy).toHaveBeenCalledTimes(33) expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [ HideableSidebarItemID.Workflows, ]) diff --git a/src-ui/src/app/components/admin/settings/settings.component.ts b/src-ui/src/app/components/admin/settings/settings.component.ts index 2fe40e540..886e47ad6 100644 --- a/src-ui/src/app/components/admin/settings/settings.component.ts +++ b/src-ui/src/app/components/admin/settings/settings.component.ts @@ -63,7 +63,7 @@ import { SettingsService, } from 'src/app/services/settings.service' import { SystemStatusService } from 'src/app/services/system-status.service' -import { Toast, ToastService } from 'src/app/services/toast.service' +import { Toast, ToastLevel, ToastService } from 'src/app/services/toast.service' import { locationReload } from 'src/app/utils/navigation' import { CheckComponent } from '../../common/input/check/check.component' import { ColorComponent } from '../../common/input/color/color.component' @@ -190,10 +190,8 @@ export class SettingsComponent searchDbOnly: new FormControl(null), searchLink: new FormControl(null), - notificationsConsumerNewDocument: new FormControl(null), notificationsConsumerSuccess: new FormControl(null), notificationsConsumerFailed: new FormControl(null), - notificationsConsumerSuppressOnDashboard: new FormControl(null), savedViewsWarnOnUnsavedChange: new FormControl(null), sidebarViewsShowCount: new FormControl(null), @@ -371,18 +369,12 @@ export class SettingsComponent updateCheckingEnabled: this.settings.get( SETTINGS_KEYS.UPDATE_CHECKING_ENABLED ), - notificationsConsumerNewDocument: this.settings.get( - SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT - ), notificationsConsumerSuccess: this.settings.get( SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS ), notificationsConsumerFailed: this.settings.get( SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED ), - notificationsConsumerSuppressOnDashboard: this.settings.get( - SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD - ), savedViewsWarnOnUnsavedChange: this.settings.get( SETTINGS_KEYS.SAVED_VIEWS_WARN_ON_UNSAVED_CHANGE ), @@ -571,10 +563,6 @@ export class SettingsComponent SETTINGS_KEYS.DATE_FORMAT, this.settingsForm.value.dateFormat ) - this.settings.set( - SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT, - this.settingsForm.value.notificationsConsumerNewDocument - ) this.settings.set( SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS, this.settingsForm.value.notificationsConsumerSuccess @@ -583,10 +571,6 @@ export class SettingsComponent SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED, this.settingsForm.value.notificationsConsumerFailed ) - this.settings.set( - SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD, - this.settingsForm.value.notificationsConsumerSuppressOnDashboard - ) this.settings.set( SETTINGS_KEYS.NOTES_ENABLED, this.settingsForm.value.notesEnabled @@ -664,8 +648,11 @@ export class SettingsComponent let savedToast: Toast = { content: $localize`Settings were saved successfully.`, delay: 5000, + level: ToastLevel.Success, + persistent: false, } if (reloadRequired) { + savedToast.persistent = true savedToast.content = $localize`Settings were saved successfully. Reload is required to apply some changes.` savedToast.actionName = $localize`Reload now` savedToast.action = () => { diff --git a/src-ui/src/app/components/admin/trash/trash.component.spec.ts b/src-ui/src/app/components/admin/trash/trash.component.spec.ts index 7edb7121e..4ad48dd43 100644 --- a/src-ui/src/app/components/admin/trash/trash.component.spec.ts +++ b/src-ui/src/app/components/admin/trash/trash.component.spec.ts @@ -99,11 +99,16 @@ describe('TrashComponent', () => { modal.componentInstance.confirmClicked.next() expect(toastErrorSpy).toHaveBeenCalled() + let toasts + toastService.getToasts().subscribe((allToasts) => { + toasts = [...allToasts] + }) trashSpy.mockReturnValue(of('OK')) component.delete(documentsInTrash[0]) expect(modal).toBeDefined() modal.componentInstance.confirmClicked.next() expect(trashSpy).toHaveBeenCalled() + expect(toasts[0].content).toContain('deleted') }) it('should support empty trash, show error if needed', () => { diff --git a/src-ui/src/app/components/admin/trash/trash.component.ts b/src-ui/src/app/components/admin/trash/trash.component.ts index 2f8fbe841..800a7c084 100644 --- a/src-ui/src/app/components/admin/trash/trash.component.ts +++ b/src-ui/src/app/components/admin/trash/trash.component.ts @@ -11,7 +11,7 @@ import { takeUntil, tap } from 'rxjs' import { Document } from 'src/app/data/document' import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { SettingsService } from 'src/app/services/settings.service' -import { ToastService } from 'src/app/services/toast.service' +import { ToastLevel, ToastService } from 'src/app/services/toast.service' import { TrashService } from 'src/app/services/trash.service' import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component' import { PageHeaderComponent } from '../../common/page-header/page-header.component' @@ -87,9 +87,11 @@ export class TrashComponent modal.componentInstance.buttonsEnabled.set(false) this.trashService.emptyTrash([document.id]).subscribe({ next: () => { - this.toastService.showInfo( - $localize`Document "${document.title}" deleted` - ) + this.toastService.show({ + content: $localize`Document "${document.title}" deleted`, + delay: 5000, + level: ToastLevel.Success, + }) modal.close() this.reload() }, @@ -122,7 +124,11 @@ export class TrashComponent .emptyTrash(documents ? Array.from(documents) : null) .subscribe({ next: () => { - this.toastService.showInfo($localize`Document(s) deleted`) + this.toastService.show({ + content: $localize`Document(s) deleted`, + delay: 5000, + level: ToastLevel.Success, + }) this.allToggled.set(false) modal.close() this.reload() @@ -144,6 +150,7 @@ export class TrashComponent this.toastService.show({ content: $localize`Document "${document.title}" restored`, delay: 5000, + level: ToastLevel.Success, actionName: $localize`Open document`, action: () => { this.router.navigate(['documents', document.id]) @@ -165,7 +172,11 @@ export class TrashComponent .restoreDocuments(documents ? Array.from(documents) : null) .subscribe({ next: () => { - this.toastService.showInfo($localize`Document(s) restored`) + this.toastService.show({ + content: $localize`Document(s) restored`, + delay: 5000, + level: ToastLevel.Success, + }) this.allToggled.set(false) this.reload() }, diff --git a/src-ui/src/app/components/admin/users-groups/users-groups.component.spec.ts b/src-ui/src/app/components/admin/users-groups/users-groups.component.spec.ts index 0c6dea611..8e2551f15 100644 --- a/src-ui/src/app/components/admin/users-groups/users-groups.component.spec.ts +++ b/src-ui/src/app/components/admin/users-groups/users-groups.component.spec.ts @@ -101,7 +101,7 @@ describe('UsersAndGroupsComponent', () => { component.editUser(users[0]) const editDialog = modal.componentInstance as UserEditDialogComponent const toastErrorSpy = jest.spyOn(toastService, 'showError') - const toastInfoSpy = jest.spyOn(toastService, 'showInfo') + const toastInfoSpy = jest.spyOn(toastService, 'showSuccess') editDialog.failed.emit() expect(toastErrorSpy).toHaveBeenCalled() settingsService.currentUser.set(users[1]) // simulate logged in as different user @@ -120,7 +120,7 @@ describe('UsersAndGroupsComponent', () => { const deleteDialog = modal.componentInstance as ConfirmDialogComponent const deleteSpy = jest.spyOn(userService, 'delete') const toastErrorSpy = jest.spyOn(toastService, 'showError') - const toastInfoSpy = jest.spyOn(toastService, 'showInfo') + const toastInfoSpy = jest.spyOn(toastService, 'showSuccess') const listAllSpy = jest.spyOn(userService, 'listAll') deleteSpy.mockReturnValueOnce( throwError(() => new Error('error deleting user')) @@ -186,7 +186,7 @@ describe('UsersAndGroupsComponent', () => { component.editGroup(groups[0]) const editDialog = modal.componentInstance as GroupEditDialogComponent const toastErrorSpy = jest.spyOn(toastService, 'showError') - const toastInfoSpy = jest.spyOn(toastService, 'showInfo') + const toastInfoSpy = jest.spyOn(toastService, 'showSuccess') editDialog.failed.emit() expect(toastErrorSpy).toHaveBeenCalled() editDialog.succeeded.emit(groups[0]) @@ -204,7 +204,7 @@ describe('UsersAndGroupsComponent', () => { const deleteDialog = modal.componentInstance as ConfirmDialogComponent const deleteSpy = jest.spyOn(groupService, 'delete') const toastErrorSpy = jest.spyOn(toastService, 'showError') - const toastInfoSpy = jest.spyOn(toastService, 'showInfo') + const toastInfoSpy = jest.spyOn(toastService, 'showSuccess') const listAllSpy = jest.spyOn(groupService, 'listAll') deleteSpy.mockReturnValueOnce( throwError(() => new Error('error deleting group')) diff --git a/src-ui/src/app/components/admin/users-groups/users-groups.component.ts b/src-ui/src/app/components/admin/users-groups/users-groups.component.ts index 5430b9e2a..406e17398 100644 --- a/src-ui/src/app/components/admin/users-groups/users-groups.component.ts +++ b/src-ui/src/app/components/admin/users-groups/users-groups.component.ts @@ -122,7 +122,7 @@ export class UsersAndGroupsComponent ) }, 2500) } else { - this.toastService.showInfo( + this.toastService.showSuccess( $localize`Saved user "${newUser.username}".` ) this.usersService.listAll().subscribe((r) => { @@ -151,7 +151,9 @@ export class UsersAndGroupsComponent this.usersService.delete(user).subscribe({ next: () => { modal.close() - this.toastService.showInfo($localize`Deleted user "${user.username}"`) + this.toastService.showSuccess( + $localize`Deleted user "${user.username}"` + ) this.usersService.listAll().subscribe((r) => { this.users.set(r.results) }) @@ -178,7 +180,9 @@ export class UsersAndGroupsComponent modal.componentInstance.succeeded .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe((newGroup) => { - this.toastService.showInfo($localize`Saved group "${newGroup.name}".`) + this.toastService.showSuccess( + $localize`Saved group "${newGroup.name}".` + ) this.groupsService.listAll().subscribe((r) => { this.groups.set(r.results) }) @@ -204,7 +208,9 @@ export class UsersAndGroupsComponent this.groupsService.delete(group).subscribe({ next: () => { modal.close() - this.toastService.showInfo($localize`Deleted group "${group.name}"`) + this.toastService.showSuccess( + $localize`Deleted group "${group.name}"` + ) this.groupsService.listAll().subscribe((r) => { this.groups.set(r.results) }) diff --git a/src-ui/src/app/components/app-frame/app-frame.component.html b/src-ui/src/app/components/app-frame/app-frame.component.html index 78d96ffa6..91fe83222 100644 --- a/src-ui/src/app/components/app-frame/app-frame.component.html +++ b/src-ui/src/app/components/app-frame/app-frame.component.html @@ -427,5 +427,6 @@ [ngClass]="slimSidebarEnabled ? 'col-slim' : 'col-sidebar-expanded'"> + diff --git a/src-ui/src/app/components/app-frame/app-frame.component.spec.ts b/src-ui/src/app/components/app-frame/app-frame.component.spec.ts index 404ca6620..60fc6b18f 100644 --- a/src-ui/src/app/components/app-frame/app-frame.component.spec.ts +++ b/src-ui/src/app/components/app-frame/app-frame.component.spec.ts @@ -507,7 +507,7 @@ describe('AppFrameComponent', () => { it('should update saved view sorting on drag + drop, show info', () => { const settingsSpy = jest.spyOn(settingsService, 'updateSidebarViewsSort') - const toastSpy = jest.spyOn(toastService, 'showInfo') + const toastSpy = jest.spyOn(toastService, 'showSuccess') jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true)) component.onDrop({ previousIndex: 0, currentIndex: 1 } as CdkDragDrop< SavedView[] @@ -549,6 +549,7 @@ describe('AppFrameComponent', () => { it('should show toasts for django messages', () => { const toastErrorSpy = jest.spyOn(toastService, 'showError') + const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess') const toastInfoSpy = jest.spyOn(toastService, 'showInfo') jest.spyOn(messagesService, 'get').mockReturnValue([ { level: DjangoMessageLevel.WARNING, message: 'Test warning' }, @@ -559,7 +560,8 @@ describe('AppFrameComponent', () => { ]) component.ngOnInit() expect(toastErrorSpy).toHaveBeenCalledTimes(2) - expect(toastInfoSpy).toHaveBeenCalledTimes(3) + expect(toastSuccessSpy).toHaveBeenCalledTimes(1) + expect(toastInfoSpy).toHaveBeenCalledTimes(2) }) it('should call maybeRefreshDocumentCounts after saved views reload', () => { 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 c18495001..f7fcc37f0 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 @@ -55,6 +55,7 @@ import { SwitchComponent } from '../common/input/switch/switch.component' import { BrandMarkComponent } from '../common/logo/brand-mark/brand-mark.component' import { LogoComponent } from '../common/logo/logo.component' import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component' +import { UploadStatusTrayComponent } from '../common/upload-status-tray/upload-status-tray.component' import { DocumentDetailComponent } from '../document-detail/document-detail.component' import { ComponentWithPermissions } from '../with-permissions/with-permissions.component' import { GlobalSearchComponent } from './global-search/global-search.component' @@ -85,6 +86,7 @@ const SCROLL_THRESHOLD = 16 TourNgBootstrap, FormsModule, SwitchComponent, + UploadStatusTrayComponent, ], }) export class AppFrameComponent @@ -173,6 +175,8 @@ export class AppFrameComponent this.toastService.showError(message.message) break case DjangoMessageLevel.SUCCESS: + this.toastService.showSuccess(message.message) + break case DjangoMessageLevel.INFO: case DjangoMessageLevel.DEBUG: this.toastService.showInfo(message.message) @@ -479,7 +483,7 @@ export class AppFrameComponent this.settingsService.updateSidebarViewsSort(sidebarViews).subscribe({ next: () => { - this.toastService.showInfo($localize`Sidebar views updated`) + this.toastService.showSuccess($localize`Sidebar views updated`) }, error: (e) => { this.toastService.showError($localize`Error updating sidebar views`, e) diff --git a/src-ui/src/app/components/app-frame/global-search/global-search.component.spec.ts b/src-ui/src/app/components/app-frame/global-search/global-search.component.spec.ts index 9d0655ca4..015984b40 100644 --- a/src-ui/src/app/components/app-frame/global-search/global-search.component.spec.ts +++ b/src-ui/src/app/components/app-frame/global-search/global-search.component.spec.ts @@ -409,7 +409,7 @@ describe('GlobalSearchComponent', () => { const editDialog = modal.componentInstance as CustomFieldEditDialogComponent const toastErrorSpy = jest.spyOn(toastService, 'showError') - const toastInfoSpy = jest.spyOn(toastService, 'showInfo') + const toastInfoSpy = jest.spyOn(toastService, 'showSuccess') // fail first editDialog.failed.emit({ error: 'error creating item' }) @@ -460,7 +460,7 @@ describe('GlobalSearchComponent', () => { const editDialog = modal.componentInstance as CustomFieldEditDialogComponent const toastErrorSpy = jest.spyOn(toastService, 'showError') - const toastInfoSpy = jest.spyOn(toastService, 'showInfo') + const toastInfoSpy = jest.spyOn(toastService, 'showSuccess') // fail first editDialog.failed.emit({ error: 'error creating item' }) diff --git a/src-ui/src/app/components/app-frame/global-search/global-search.component.ts b/src-ui/src/app/components/app-frame/global-search/global-search.component.ts index 9a70189a6..80f9ce8c2 100644 --- a/src-ui/src/app/components/app-frame/global-search/global-search.component.ts +++ b/src-ui/src/app/components/app-frame/global-search/global-search.component.ts @@ -216,7 +216,7 @@ export class GlobalSearchComponent implements OnInit { modalRef.componentInstance.dialogMode.set(EditDialogMode.EDIT) modalRef.componentInstance.object = object modalRef.componentInstance.succeeded.subscribe(() => { - this.toastService.showInfo($localize`Successfully updated object.`) + this.toastService.showSuccess($localize`Successfully updated object.`) }) modalRef.componentInstance.failed.subscribe((e) => { this.toastService.showError($localize`Error occurred saving object.`, e) @@ -254,7 +254,7 @@ export class GlobalSearchComponent implements OnInit { modalRef.componentInstance.dialogMode.set(EditDialogMode.EDIT) modalRef.componentInstance.object = object modalRef.componentInstance.succeeded.subscribe(() => { - this.toastService.showInfo($localize`Successfully updated object.`) + this.toastService.showSuccess($localize`Successfully updated object.`) }) modalRef.componentInstance.failed.subscribe((e) => { this.toastService.showError($localize`Error occurred saving object.`, e) 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 index 1840d6453..53b28e15d 100644 --- 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 @@ -1,28 +1,31 @@ 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 index 71bd9a6ff..132d3f21b 100644 --- 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 @@ -30,3 +30,7 @@ right: -3rem; } } + +.clear-all:hover { + color: var(--bs-body-color) !important; +} 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 index 42a891642..bad2fdb6b 100644 --- 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 @@ -3,7 +3,7 @@ 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 { Toast, ToastLevel, ToastService } from 'src/app/services/toast.service' import { ToastsDropdownComponent } from './toasts-dropdown.component' const toasts = [ @@ -90,4 +90,73 @@ describe('ToastsDropdownComponent', () => { component.onOpenChange(true) expect(spy).toHaveBeenCalledWith(true) }) + + it('should badge unread toasts, red when one is an error', () => { + toastsSubject.next([ + { id: 'a', content: 'Read', delay: 5000, read: true }, + { id: 'b', content: 'Unread', delay: 5000, read: false }, + ]) + fixture.detectChanges() + let badge: HTMLElement = fixture.nativeElement.querySelector( + '.notification-count' + ) + expect(badge.textContent.trim()).toEqual('1') + expect(badge.classList).toContain('bg-secondary') + + toastsSubject.next([ + { id: 'c', content: 'Failed', delay: 5000, level: ToastLevel.Error }, + ]) + fixture.detectChanges() + badge = fixture.nativeElement.querySelector('.notification-count') + expect(badge.classList).toContain('bg-danger') + + toastsSubject.next([{ id: 'd', content: 'Seen', delay: 5000, read: true }]) + fixture.detectChanges() + expect( + fixture.nativeElement.querySelector('.notification-count') + ).toBeNull() + }) + + it('should mark all toasts read when opened', () => { + const markSpy = jest.spyOn(toastService, 'markAllRead') + component.onOpenChange(false) + expect(markSpy).not.toHaveBeenCalled() + component.onOpenChange(true) + expect(markSpy).toHaveBeenCalled() + }) + + it('should tick the clock only while open', () => { + jest.useFakeTimers() + component.onOpenChange(true) + const opened = component.now() + jest.advanceTimersByTime(30000) + const ticked = component.now() + expect(ticked).not.toBe(opened) + + component.onOpenChange(false) + jest.advanceTimersByTime(60000) + expect(component.now()).toBe(ticked) + jest.useRealTimers() + }) + + it('should mark toasts that were unread when opened as new', () => { + toastsSubject.next([ + { id: 'a', content: 'Seen', delay: 5000, read: true }, + { id: 'b', content: 'Unseen', delay: 5000, read: false }, + ]) + fixture.detectChanges() + component.onOpenChange(true) + expect([...component.newIds()]).toEqual(['b']) + component.onOpenChange(false) + expect(component.newIds().size).toEqual(0) + }) + + it('should clear the toast and close the menu when an action is used', () => { + const toast = { id: 'a', content: 'Moved', delay: 5000 } + const closeToastSpy = jest.spyOn(toastService, 'closeToast') + const closeSpy = jest.spyOn(component.dropdown, 'close') + component.onActioned(toast) + expect(closeToastSpy).toHaveBeenCalledWith(toast) + expect(closeSpy).toHaveBeenCalled() + }) }) 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 index b0bcafd68..c2b758af4 100644 --- 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 @@ -1,12 +1,20 @@ -import { Component, inject } from '@angular/core' -import { toSignal } from '@angular/core/rxjs-interop' import { + Component, + DestroyRef, + ViewChild, + computed, + inject, + signal, +} from '@angular/core' +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop' +import { + NgbDropdown, NgbDropdownModule, NgbProgressbarModule, } from '@ng-bootstrap/ng-bootstrap' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' -import { map } from 'rxjs' -import { Toast, ToastService } from 'src/app/services/toast.service' +import { Subscription, interval, map } from 'rxjs' +import { Toast, ToastLevel, ToastService } from 'src/app/services/toast.service' import { ToastComponent } from '../../common/toast/toast.component' @Component({ @@ -22,13 +30,44 @@ import { ToastComponent } from '../../common/toast/toast.component' }) export class ToastsDropdownComponent { toastService = inject(ToastService) + private destroyRef = inject(DestroyRef) + private clock: Subscription + + @ViewChild(NgbDropdown) dropdown: NgbDropdown + + /** Drives the relative timestamps, ticks only while the dropdown is open */ + readonly now = signal(new Date()) + + /** Notifications that were unread when the dropdown was opened */ + readonly newIds = signal(new Set()) readonly toasts = toSignal( this.toastService.getToasts().pipe(map((toasts) => [...toasts])), { initialValue: [] as Toast[] } ) + readonly unread = computed(() => this.toasts().filter((t) => !t.read)) + + readonly hasUnreadError = computed(() => + this.unread().some((t) => t.level === ToastLevel.Error) + ) + onOpenChange(open: boolean): void { this.toastService.suppressPopupToasts = open + this.clock?.unsubscribe() + this.newIds.set(new Set(open ? this.unread().map((t) => t.id) : [])) + if (open) { + this.toastService.markAllRead() + this.now.set(new Date()) + this.clock = interval(30000) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.now.set(new Date())) + } + } + + /** Using an action (Undo, Open document...) clears it and closes the menu */ + onActioned(toast: Toast): void { + this.toastService.closeToast(toast) + this.dropdown?.close() } } diff --git a/src-ui/src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.spec.ts b/src-ui/src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.spec.ts index 3de750ce2..e00a6e0d1 100644 --- a/src-ui/src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.spec.ts +++ b/src-ui/src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.spec.ts @@ -110,7 +110,7 @@ describe('CustomFieldsDropdownComponent', () => { 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 toastInfoSpy = jest.spyOn(toastService, 'showSuccess') const getFieldsSpy = jest.spyOn( CustomFieldsDropdownComponent.prototype as any, 'getFields' diff --git a/src-ui/src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.ts b/src-ui/src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.ts index ea68fa4b9..6d920742a 100644 --- a/src-ui/src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.ts +++ b/src-ui/src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.ts @@ -129,7 +129,9 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio modal.componentInstance.succeeded .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe((newField) => { - this.toastService.showInfo($localize`Saved field "${newField.name}".`) + this.toastService.showSuccess( + $localize`Saved field "${newField.name}".` + ) this.customFieldsService.clearCache() this.getFields() this.created.emit(newField) diff --git a/src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.spec.ts b/src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.spec.ts index f63a30657..0769eaaf4 100644 --- a/src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.spec.ts +++ b/src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.spec.ts @@ -151,7 +151,7 @@ describe('UserEditDialogComponent', () => { 'deactivateTotp' ) const toastErrorSpy = jest.spyOn(toastService, 'showError') - const toastInfoSpy = jest.spyOn(toastService, 'showInfo') + const toastInfoSpy = jest.spyOn(toastService, 'showSuccess') deactivateSpy.mockReturnValueOnce(throwError(() => new Error('error'))) component.deactivateTotp() expect(deactivateSpy).toHaveBeenCalled() diff --git a/src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts b/src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts index cd9577545..c860d0180 100644 --- a/src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts +++ b/src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts @@ -132,7 +132,7 @@ export class UserEditDialogComponent next: (result) => { this.totpLoading.set(false) if (result) { - this.toastService.showInfo($localize`Totp deactivated`) + this.toastService.showSuccess($localize`Totp deactivated`) this.object.is_mfa_enabled = false } else { this.toastService.showError($localize`Totp deactivation failed`) diff --git a/src-ui/src/app/components/common/email-document-dialog/email-document-dialog.component.spec.ts b/src-ui/src/app/components/common/email-document-dialog/email-document-dialog.component.spec.ts index a9b885e1a..ca078bab6 100644 --- a/src-ui/src/app/components/common/email-document-dialog/email-document-dialog.component.spec.ts +++ b/src-ui/src/app/components/common/email-document-dialog/email-document-dialog.component.spec.ts @@ -52,7 +52,7 @@ describe('EmailDocumentDialogComponent', () => { it('should support sending single document via email, showing error if needed', () => { const toastErrorSpy = jest.spyOn(toastService, 'showError') - const toastSuccessSpy = jest.spyOn(toastService, 'showInfo') + const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess') component.documentIds.set([1]) component.emailAddress = 'hello@paperless-ngx.com' component.emailSubject = 'Hello' @@ -73,7 +73,7 @@ describe('EmailDocumentDialogComponent', () => { it('should support sending multiple documents via email, showing appropriate messages', () => { const toastErrorSpy = jest.spyOn(toastService, 'showError') - const toastSuccessSpy = jest.spyOn(toastService, 'showInfo') + const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess') component.documentIds.set([1, 2, 3]) component.emailAddress = 'hello@paperless-ngx.com' component.emailSubject = 'Hello' diff --git a/src-ui/src/app/components/common/email-document-dialog/email-document-dialog.component.ts b/src-ui/src/app/components/common/email-document-dialog/email-document-dialog.component.ts index 704a495d8..c97d8be0f 100644 --- a/src-ui/src/app/components/common/email-document-dialog/email-document-dialog.component.ts +++ b/src-ui/src/app/components/common/email-document-dialog/email-document-dialog.component.ts @@ -50,7 +50,7 @@ export class EmailDocumentDialogComponent extends LoadingComponentWithPermission this.emailSubject = '' this.emailMessage = '' this.close() - this.toastService.showInfo($localize`Email sent`) + this.toastService.showSuccess($localize`Email sent`) }, error: (e) => { this.loading.set(false) diff --git a/src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.spec.ts b/src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.spec.ts index be704527b..aaf70737c 100644 --- a/src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.spec.ts +++ b/src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.spec.ts @@ -100,7 +100,7 @@ describe('ProfileEditDialogComponent', () => { expect(errorSpy).toHaveBeenCalled() updateSpy.mockClear() - const infoSpy = jest.spyOn(toastService, 'showInfo') + const infoSpy = jest.spyOn(toastService, 'showSuccess') component.form.patchValue(newProfile) updateSpy.mockReturnValueOnce(of(newProfile)) component.save() @@ -373,7 +373,7 @@ 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 toastInfoSpy = jest.spyOn(toastService, 'showSuccess') const error = new Error('failed to activate totp') activateSpy.mockReturnValueOnce(throwError(() => error)) component.totpSettings.set({ @@ -405,7 +405,7 @@ describe('ProfileEditDialogComponent', () => { it('should deactivate totp', () => { const deactivateSpy = jest.spyOn(profileService, 'deactivateTotp') const toastErrorSpy = jest.spyOn(toastService, 'showError') - const toastInfoSpy = jest.spyOn(toastService, 'showInfo') + const toastInfoSpy = jest.spyOn(toastService, 'showSuccess') const error = new Error('failed to deactivate totp') deactivateSpy.mockReturnValueOnce(throwError(() => error)) component.deactivateTotp() diff --git a/src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts b/src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts index 6c9ce6c72..1b1489d28 100644 --- a/src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts +++ b/src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts @@ -204,7 +204,7 @@ export class ProfileEditDialogComponent .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe({ next: () => { - this.toastService.showInfo($localize`Profile updated successfully`) + this.toastService.showSuccess($localize`Profile updated successfully`) if (passwordChanged) { this.toastService.showInfo( $localize`Password has been changed, you will be logged out momentarily.` @@ -307,7 +307,9 @@ export class ProfileEditDialogComponent this.recoveryCodes.set(activationResponse.recovery_codes) this.form.get('totp_code').enable() if (activationResponse.success) { - this.toastService.showInfo($localize`TOTP activated successfully`) + this.toastService.showSuccess( + $localize`TOTP activated successfully` + ) } else { this.toastService.showError($localize`Error activating TOTP`) } @@ -331,7 +333,9 @@ export class ProfileEditDialogComponent this.isTotpEnabled.set(!success) this.recoveryCodes.set(null) if (success) { - this.toastService.showInfo($localize`TOTP deactivated successfully`) + this.toastService.showSuccess( + $localize`TOTP deactivated successfully` + ) } else { this.toastService.showError($localize`Error deactivating TOTP`) } diff --git a/src-ui/src/app/components/common/toast/toast.component.html b/src-ui/src/app/components/common/toast/toast.component.html index f241d3cea..a05e64214 100644 --- a/src-ui/src/app/components/common/toast/toast.component.html +++ b/src-ui/src/app/components/common/toast/toast.component.html @@ -1,22 +1,33 @@ @if (autohide) { - - {{ toast.delayRemaining / 1000 | number: '1.0-0' }} seconds + + {{ delayRemaining() / 1000 | number: '1.0-0' }} seconds }
- @if (!toast.error) { - + @switch (toast.level) { + @case (ToastLevel.Success) { + + } + @case (ToastLevel.Error) { + + } + @default { + + } } - @if (toast.error) { - - } -
+

{{toast.content}}

@if (toast.error) {
@@ -48,9 +59,12 @@
} @if (toast.action) { -

+ }
+ @if (!autohide && toast.timestamp) { + {{relativeTimestamp}} + }
diff --git a/src-ui/src/app/components/common/toast/toast.component.scss b/src-ui/src/app/components/common/toast/toast.component.scss index 3783445de..47df1a158 100644 --- a/src-ui/src/app/components/common/toast/toast.component.scss +++ b/src-ui/src/app/components/common/toast/toast.component.scss @@ -2,19 +2,105 @@ position: relative; } -::ng-deep .toast.error { - border-color: hsla(350, 79%, 40%, 0.4); // bg-danger +.toast { + --pngx-toast-accent: var(--pngx-toast-info); + background-color: var(--pngx-toast-bg); + color: var(--bs-body-color); + overflow: hidden; + + &.toast-success { + --pngx-toast-accent: var(--pngx-toast-success); + background-color: var(--pngx-toast-success-bg); + border-color: color-mix(in srgb, var(--pngx-toast-success) 30%, transparent); + } + + &.toast-error { + --pngx-toast-accent: var(--pngx-toast-error); + background-color: var(--pngx-toast-error-bg); + border-color: color-mix(in srgb, var(--pngx-toast-error) 35%, transparent); + } } -::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; +.toast-icon { + color: var(--pngx-toast-accent); } +.toast-content { + flex: 1 1 auto; + min-width: 0; +} + +.toast .btn-close { + font-size: .6rem; + margin-top: .3rem; + margin-left: .75rem !important; + opacity: .45; +} + +.toast:hover .btn-close { + opacity: .85; +} + +// compact rows in the notifications dropdown +.toast.toast-list-item { + width: 100%; + max-width: none; + background-color: var(--pngx-toast-row-bg); + border: 0; + box-shadow: none; + + &:hover { + background-color: var(--pngx-toast-row-hover-bg); + } + + &.toast-error { + background-color: var(--pngx-toast-error-bg); + } + + ::ng-deep .toast-body { + padding: .625rem .75rem; + } + + .toast-time { + margin-top: .1rem; + } + + // marks notifications that arrived since the dropdown was last opened + &.toast-new .toast-time::before { + content: ''; + display: inline-block; + width: .45rem; + height: .45rem; + margin-right: .4rem; + vertical-align: .05rem; + border-radius: 50%; + background-color: var(--pngx-toast-info); + } +} + +.toast-action { + --bs-btn-padding-y: .1rem; + --bs-btn-padding-x: .6rem; + --bs-btn-font-size: .8rem; + --bs-btn-font-weight: 600; + --bs-btn-color: var(--pngx-toast-accent); + --bs-btn-bg: color-mix(in srgb, var(--pngx-toast-accent) 14%, transparent); + --bs-btn-border-color: transparent; + --bs-btn-hover-color: var(--pngx-toast-accent); + --bs-btn-hover-bg: color-mix(in srgb, var(--pngx-toast-accent) 24%, transparent); + --bs-btn-hover-border-color: transparent; + --bs-btn-active-color: var(--pngx-toast-accent); + --bs-btn-active-bg: color-mix(in srgb, var(--pngx-toast-accent) 30%, transparent); + --bs-btn-active-border-color: transparent; +} + +// countdown thin line .progress { - background-color: var(--pngx-primary); - opacity: .07; + height: 2px; + background-color: transparent; + + ::ng-deep .progress-bar { + background-color: var(--bs-secondary-color); + opacity: .5; + } } 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 index 2d0eb8341..ff73f3038 100644 --- a/src-ui/src/app/components/common/toast/toast.component.spec.ts +++ b/src-ui/src/app/components/common/toast/toast.component.spec.ts @@ -1,7 +1,11 @@ +import { DatePipe } from '@angular/common' +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' +import { provideHttpClientTesting } from '@angular/common/http/testing' import { ComponentFixture, TestBed } from '@angular/core/testing' import { Clipboard } from '@angular/cdk/clipboard' import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' +import { ToastLevel } from 'src/app/services/toast.service' import { ToastComponent } from './toast.component' const toast1 = { @@ -30,6 +34,11 @@ describe('ToastComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [ToastComponent, NgxBootstrapIconsModule.pick(allIcons)], + providers: [ + DatePipe, + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + ], }).compileComponents() fixture = TestBed.createComponent(ToastComponent) @@ -46,11 +55,117 @@ describe('ToastComponent', () => { component.toast = toast2 fixture.detectChanges() component.onShown(toast2) - jest.advanceTimersByTime(5000) - expect(component.toast.delayRemaining).toEqual(0) + jest.advanceTimersByTime(5000 + 150) // ticks every 150ms + expect(component.delayRemaining()).toEqual(0) jest.useRealTimers() }) + it('should start the progress bar full before the countdown begins', () => { + component.toast = { content: 'Info', delay: 3000 } + fixture.detectChanges() + expect(component.delayRemaining()).toEqual(3000) + }) + + it('should hide the toast when the countdown ends', () => { + jest.useFakeTimers() + const toast = { content: 'Info', delay: 3000 } + component.toast = toast + fixture.detectChanges() + const hideSpy = jest.spyOn(component.ngbToast, 'hide') + component.onShown(toast) + jest.advanceTimersByTime(2850) + expect(hideSpy).not.toHaveBeenCalled() + jest.advanceTimersByTime(300) + expect(hideSpy).toHaveBeenCalled() + jest.useRealTimers() + }) + + it('should pause the countdown while hovered', () => { + jest.useFakeTimers() + const toast = { content: 'Info', delay: 3000 } + component.toast = toast + fixture.detectChanges() + const hideSpy = jest.spyOn(component.ngbToast, 'hide') + const el: HTMLElement = fixture.nativeElement.querySelector('ngb-toast') + component.onShown(toast) + jest.advanceTimersByTime(1500) + el.dispatchEvent(new MouseEvent('mouseenter')) + const remaining = component.delayRemaining() + jest.advanceTimersByTime(10000) + expect(component.delayRemaining()).toEqual(remaining) + expect(hideSpy).not.toHaveBeenCalled() + + el.dispatchEvent(new MouseEvent('mouseleave')) + jest.advanceTimersByTime(remaining + 150) + expect(hideSpy).toHaveBeenCalled() + jest.useRealTimers() + }) + + it('should pause the countdown while focus is inside', () => { + jest.useFakeTimers() + const toast = { content: 'Info', delay: 3000 } + component.toast = toast + fixture.detectChanges() + const el: HTMLElement = fixture.nativeElement.querySelector('ngb-toast') + component.onShown(toast) + el.dispatchEvent(new FocusEvent('focusin')) + // mouse leaving doesn't resume while focus is still inside + el.dispatchEvent(new MouseEvent('mouseenter')) + el.dispatchEvent(new MouseEvent('mouseleave')) + jest.advanceTimersByTime(5000) + expect(component.delayRemaining()).toEqual(3000) + el.dispatchEvent(new FocusEvent('focusout')) + jest.advanceTimersByTime(3000) + expect(component.delayRemaining()).toEqual(0) + jest.useRealTimers() + }) + + it('should not count down without autohide', () => { + jest.useFakeTimers() + const toast = { content: 'Info', delay: 3000 } + component.toast = toast + component.autohide = false + fixture.detectChanges() + component.onShown(toast) + jest.advanceTimersByTime(5000) + expect(component.delayRemaining()).toBeNull() + jest.useRealTimers() + }) + + it('should show an icon and colors for each level', () => { + component.toast = { + content: 'Saved', + delay: 5000, + level: ToastLevel.Success, + } + fixture.detectChanges() + const el: HTMLElement = fixture.nativeElement + expect(el.querySelector('i-bs[name="check-circle"]')).not.toBeNull() + expect(el.querySelector('ngb-toast').classList).not.toContain('toast-error') + + fixture = TestBed.createComponent(ToastComponent) + fixture.componentInstance.toast = { + content: 'Failed', + delay: 5000, + level: ToastLevel.Error, + } + fixture.detectChanges() + const errorEl: HTMLElement = fixture.nativeElement + expect( + errorEl.querySelector('i-bs[name="exclamation-triangle"]') + ).not.toBeNull() + expect(errorEl.querySelector('ngb-toast').classList).toContain( + 'toast-error' + ) + + fixture = TestBed.createComponent(ToastComponent) + fixture.componentInstance.toast = { content: 'Started', delay: 5000 } + fixture.detectChanges() + expect( + fixture.nativeElement.querySelector('i-bs[name="info-circle"]') + ).not.toBeNull() + }) + it('should show an error if given with toast', () => { component.toast = toast1 fixture.detectChanges() @@ -88,4 +203,70 @@ describe('ToastComponent', () => { component.getErrorText({ error: new Array(205).join('a') }) ).toContain('...') }) + + it('should emit actioned, not closed, when the action is clicked', () => { + const action = jest.fn() + component.toast = { + content: 'Moved', + delay: 5000, + action, + actionName: 'Undo', + } + fixture.detectChanges() + const closedSpy = jest.spyOn(component.closed, 'emit') + const actionedSpy = jest.spyOn(component.actioned, 'emit') + const button: HTMLButtonElement = [ + ...fixture.nativeElement.querySelectorAll('button'), + ].find((b) => b.textContent.includes('Undo')) + button.click() + expect(actionedSpy).toHaveBeenCalled() + expect(closedSpy).not.toHaveBeenCalled() + expect(action).toHaveBeenCalled() + }) + + it('should show a relative timestamp in the notifications list', () => { + component.toast = { content: 'Saved', delay: 5000, timestamp: new Date() } + component.autohide = false + fixture.detectChanges() + expect(fixture.nativeElement.textContent).toContain('Just now') + }) + + it('should update the relative timestamp as time passes', () => { + jest.useFakeTimers() + jest.setSystemTime(new Date('2026-10-19T12:00:00Z')) + component.toast = { content: 'Saved', delay: 5000, timestamp: new Date() } + component.autohide = false + fixture.componentRef.setInput('now', new Date()) + fixture.detectChanges() + expect(fixture.nativeElement.textContent).toContain('Just now') + + jest.setSystemTime(new Date('2026-10-19T12:03:00Z')) + fixture.componentRef.setInput('now', new Date()) + fixture.detectChanges() + expect(fixture.nativeElement.textContent).toContain('3 minutes ago') + jest.useRealTimers() + }) + + it('should show a compact action link in the notifications list', () => { + const action = jest.fn() + component.toast = { + content: 'Moved', + delay: 5000, + action, + actionName: 'Undo', + timestamp: new Date(), + } + component.autohide = false + fixture.detectChanges() + const link: HTMLButtonElement = + fixture.nativeElement.querySelector('.toast-action') + expect(link.textContent).toContain('Undo') + expect( + fixture.nativeElement.querySelector('.btn-outline-secondary') + ).toBeNull() + const actionedSpy = jest.spyOn(component.actioned, 'emit') + link.click() + expect(actionedSpy).toHaveBeenCalled() + expect(action).toHaveBeenCalled() + }) }) diff --git a/src-ui/src/app/components/common/toast/toast.component.ts b/src-ui/src/app/components/common/toast/toast.component.ts index 7ab75d1ad..61e5eb88c 100644 --- a/src-ui/src/app/components/common/toast/toast.component.ts +++ b/src-ui/src/app/components/common/toast/toast.component.ts @@ -1,20 +1,26 @@ import { Clipboard } from '@angular/cdk/clipboard' -import { DecimalPipe } from '@angular/common' +import { DatePipe, DecimalPipe } from '@angular/common' import { Component, + DestroyRef, EventEmitter, Input, + OnInit, Output, + ViewChild, inject, signal, } from '@angular/core' +import { takeUntilDestroyed } from '@angular/core/rxjs-interop' import { NgbProgressbarModule, + NgbToast, 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' +import { Subscription, interval, takeWhile } from 'rxjs' +import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' +import { Toast, ToastLevel } from 'src/app/services/toast.service' @Component({ selector: 'pngx-toast', @@ -26,32 +32,79 @@ import { Toast } from 'src/app/services/toast.service' ], templateUrl: './toast.component.html', styleUrl: './toast.component.scss', + providers: [DatePipe, CustomDatePipe], }) -export class ToastComponent { +export class ToastComponent implements OnInit { private clipboard = inject(Clipboard) + private customDatePipe = inject(CustomDatePipe) + private destroyRef = inject(DestroyRef) readonly copied = signal(false) + readonly ToastLevel = ToastLevel + readonly delayRemaining = signal(null) + + @ViewChild(NgbToast) ngbToast: NgbToast + + private hovered = false + private focused = false + private countdown: Subscription @Input() toast: Toast @Input() autohide: boolean = true + /** Each new value re-renders the relative timestamp */ + @Input() now: Date + + /** Marks a notification that arrived since the dropdown was last opened */ + @Input() isNew: boolean = false + @Output() hidden: EventEmitter = new EventEmitter() @Output() closed: EventEmitter = new EventEmitter() + /** The toast's action button was clicked */ + @Output() actioned: EventEmitter = new EventEmitter() + + ngOnInit(): void { + if (this.autohide) this.delayRemaining.set(this.toast.delay) + } + + get relativeTimestamp(): string { + return this.customDatePipe.transform(this.toast.timestamp, 'relative') + } + + get paused(): boolean { + return this.hovered || this.focused + } + + setHovered(hovered: boolean) { + this.hovered = hovered + } + + setFocused(focused: boolean) { + this.focused = focused + } + onShown(toast: Toast) { if (!this.autohide) return const refreshInterval = 150 - const delay = toast.delay - 500 // for fade animation + this.delayRemaining.set(toast.delay) - interval(refreshInterval) - .pipe(take(Math.round(delay / refreshInterval))) - .subscribe((count) => { - toast.delayRemaining = Math.max( - 0, - delay - refreshInterval * (count + 1) + this.countdown?.unsubscribe() + this.countdown = interval(refreshInterval) + .pipe( + takeWhile(() => this.delayRemaining() > 0), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(() => { + if (this.paused) return + this.delayRemaining.update((remaining) => + Math.max(0, remaining - refreshInterval) ) + if (this.delayRemaining() === 0) { + this.ngbToast?.hide() + } }) } 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 d04c4dc7b..97363aafc 100644 --- a/src-ui/src/app/components/common/toasts/toasts.component.html +++ b/src-ui/src/app/components/common/toasts/toasts.component.html @@ -1,3 +1,3 @@ @for (toast of toasts(); track toast.id) { - + } 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 1e7c686dc..be9ca1024 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,10 +1,11 @@ import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' import { provideHttpClientTesting } from '@angular/common/http/testing' import { ComponentFixture, TestBed } from '@angular/core/testing' +import { By } from '@angular/platform-browser' 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' +import { MAX_VISIBLE_TOASTS, ToastsComponent } from './toasts.component' const toast = { content: 'Error 2 content', @@ -50,7 +51,7 @@ describe('ToastsComponent', () => { it('should close toast', () => { toastSubject.next(toast) const closeToastSpy = jest.spyOn(toastService, 'closeToast') - component.closeToast() + component.closeToast(toast) expect(component.toasts()).toEqual([]) expect(closeToastSpy).toHaveBeenCalledWith(toast) }) @@ -59,4 +60,59 @@ describe('ToastsComponent', () => { toastSubject.next(toast) expect(component.toasts()).toEqual([toast]) }) + + it('should stack popups newest first, up to the maximum', () => { + const toasts: Toast[] = Array.from( + { length: MAX_VISIBLE_TOASTS + 1 }, + (_, i) => ({ id: `${i}`, content: `Toast ${i}`, delay: 5000 }) + ) + toasts.forEach((t) => toastSubject.next(t)) + expect(component.toasts().map((t) => t.id)).toEqual(['3', '2', '1']) + + // showing a visible toast again moves it to the top without duplicating + toastSubject.next(toasts[1]) + expect(component.toasts().map((t) => t.id)).toEqual(['1', '3', '2']) + }) + + it('should remove a popup when it hides without closing it', () => { + const closeToastSpy = jest.spyOn(toastService, 'closeToast') + const first = { id: 'a', content: 'A', delay: 5000 } + const second = { id: 'b', content: 'B', delay: 5000 } + toastSubject.next(first) + toastSubject.next(second) + component.removePopup(first) + expect(component.toasts()).toEqual([second]) + expect(closeToastSpy).not.toHaveBeenCalled() + }) + + it('should clear popups when toastService emits null', () => { + toastSubject.next(toast) + toastSubject.next(null) + expect(component.toasts()).toEqual([]) + }) + + it('should render one toast component per popup', () => { + toastSubject.next({ id: 'a', content: 'A', delay: 5000 }) + toastSubject.next({ id: 'b', content: 'B', delay: 5000 }) + fixture.detectChanges() + expect(fixture.nativeElement.querySelectorAll('pngx-toast')).toHaveLength(2) + }) + + it('should only hide the popup on close, but clear it on action', () => { + const closeToastSpy = jest.spyOn(toastService, 'closeToast') + const first = { id: 'a', content: 'A', delay: 5000 } + const second = { id: 'b', content: 'B', delay: 5000 } + toastSubject.next(first) + toastSubject.next(second) + fixture.detectChanges() + const popups = fixture.debugElement.queryAll(By.css('pngx-toast')) + + popups[1].triggerEventHandler('closed', first) + expect(closeToastSpy).not.toHaveBeenCalled() + expect(component.toasts()).toEqual([second]) + + popups[0].triggerEventHandler('actioned', second) + expect(closeToastSpy).toHaveBeenCalledWith(second) + expect(component.toasts()).toEqual([]) + }) }) 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 ab1f71154..d37b2f681 100644 --- a/src-ui/src/app/components/common/toasts/toasts.component.ts +++ b/src-ui/src/app/components/common/toasts/toasts.component.ts @@ -1,14 +1,18 @@ -import { Component, inject } from '@angular/core' -import { toSignal } from '@angular/core/rxjs-interop' +import { Component, inject, signal } from '@angular/core' +import { takeUntilDestroyed } from '@angular/core/rxjs-interop' import { NgbAccordionModule, NgbProgressbarModule, } from '@ng-bootstrap/ng-bootstrap' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' -import { map, merge, Subject } from 'rxjs' import { Toast, ToastService } from 'src/app/services/toast.service' import { ToastComponent } from '../toast/toast.component' +/** + * Maximum number of popup toasts shown at once + */ +export const MAX_VISIBLE_TOASTS = 3 + @Component({ selector: 'pngx-toasts', templateUrl: './toasts.component.html', @@ -23,18 +27,31 @@ import { ToastComponent } from '../toast/toast.component' export class ToastsComponent { toastService = inject(ToastService) - private readonly closedToast = new Subject() + readonly toasts = signal([]) - readonly toasts = toSignal( - merge( - this.toastService.showToast.pipe(map((toast) => (toast ? [toast] : []))), - this.closedToast.pipe(map(() => [] as Toast[])) - ), - { initialValue: [] as Toast[] } - ) + constructor() { + this.toastService.showToast + .pipe(takeUntilDestroyed()) + .subscribe((toast) => { + if (!toast) { + this.toasts.set([]) + return + } + this.toasts.update((toasts) => + [toast, ...toasts.filter((t) => t.id !== toast.id)].slice( + 0, + MAX_VISIBLE_TOASTS + ) + ) + }) + } - closeToast() { - this.toastService.closeToast(this.toasts()[0]) - this.closedToast.next() + closeToast(toast: Toast) { + this.toastService.closeToast(toast) + this.removePopup(toast) + } + + removePopup(toast: Toast) { + this.toasts.update((toasts) => toasts.filter((t) => t.id !== toast.id)) } } diff --git a/src-ui/src/app/components/common/upload-status-tray/upload-status-tray.component.html b/src-ui/src/app/components/common/upload-status-tray/upload-status-tray.component.html new file mode 100644 index 000000000..6cc94b708 --- /dev/null +++ b/src-ui/src/app/components/common/upload-status-tray/upload-status-tray.component.html @@ -0,0 +1,52 @@ +@if (canAddDocuments && getStatus().length > 0) { +
+
+
+
+
+

{{getStatusSummary()}}

+
+ @if (getStatusCompleted().length > 0) { + + Dismiss completed + + + } + +
+
+ @if (!collapsed()) { + @for (status of getStatus(); track status) { + + } + } +
+
+
+
+} + + + +
{{status.filename}}
+ @if (!isFinished(status) || (isFinished(status) && !status.documentId)) { +

{{status.message}}

+ } + +
+ @if (isFinished(status)) { +
+ @if (status.documentId) { + + Open document + + + } +
+ } +
+
+
diff --git a/src-ui/src/app/components/common/upload-status-tray/upload-status-tray.component.scss b/src-ui/src/app/components/common/upload-status-tray/upload-status-tray.component.scss new file mode 100644 index 000000000..f79cd6f66 --- /dev/null +++ b/src-ui/src/app/components/common/upload-status-tray/upload-status-tray.component.scss @@ -0,0 +1,36 @@ +:host ::ng-deep i-bs svg { + margin-top: -3px; +} + +.alert-heading { + font-size: 80%; + font-weight: bold; +} + +.btn-open { + line-height: 1; + + i-bs { + margin-top: -1px; + } +} + +.consumer-status-card { + background-color: rgba(var(--bs-body-bg-rgb), .95) !important; +} + +.consumer-status-list { + max-height: 60vh; + z-index: 1030; +} + +.progress { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + height: auto; + mix-blend-mode: soft-light; + pointer-events: none; +} diff --git a/src-ui/src/app/components/common/upload-status-tray/upload-status-tray.component.spec.ts b/src-ui/src/app/components/common/upload-status-tray/upload-status-tray.component.spec.ts new file mode 100644 index 000000000..3482df1e4 --- /dev/null +++ b/src-ui/src/app/components/common/upload-status-tray/upload-status-tray.component.spec.ts @@ -0,0 +1,186 @@ +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' +import { provideHttpClientTesting } from '@angular/common/http/testing' +import { ComponentFixture, TestBed } from '@angular/core/testing' +import { By } from '@angular/platform-browser' +import { RouterTestingModule } from '@angular/router/testing' +import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' +import { routes } from 'src/app/app-routing.module' +import { PermissionsGuard } from 'src/app/guards/permissions.guard' +import { PermissionsService } from 'src/app/services/permissions.service' +import { + FileStatus, + FileStatusPhase, + WebsocketStatusService, +} from 'src/app/services/websocket-status.service' +import { UploadStatusTrayComponent } from './upload-status-tray.component' + +const FAILED_STATUSES = [new FileStatus()] +const WORKING_STATUSES = [new FileStatus(), new FileStatus()] +const STARTED_STATUSES = [new FileStatus(), new FileStatus(), new FileStatus()] +const SUCCESS_STATUSES = [ + new FileStatus(), + new FileStatus(), + new FileStatus(), + new FileStatus(), +] +const DEFAULT_STATUSES = [ + new FileStatus(), + new FileStatus(), + new FileStatus(), + new FileStatus(), + new FileStatus(), + new FileStatus(), +] + +describe('UploadStatusTrayComponent', () => { + let component: UploadStatusTrayComponent + let fixture: ComponentFixture + let websocketStatusService: WebsocketStatusService + + beforeEach(async () => { + TestBed.configureTestingModule({ + imports: [ + RouterTestingModule.withRoutes(routes), + NgxBootstrapIconsModule.pick(allIcons), + UploadStatusTrayComponent, + ], + providers: [ + PermissionsGuard, + { + provide: PermissionsService, + useValue: { + currentUserCan: () => true, + }, + }, + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + ], + }).compileComponents() + + websocketStatusService = TestBed.inject(WebsocketStatusService) + fixture = TestBed.createComponent(UploadStatusTrayComponent) + component = fixture.componentInstance + + fixture.detectChanges() + }) + + it('should not render without statuses', () => { + expect( + fixture.nativeElement.querySelector('.consumer-status-card') + ).toBeNull() + }) + + it('should render statuses and support collapsing', () => { + const status = new FileStatus() + status.filename = 'invoice.pdf' + status.phase = FileStatusPhase.WORKING + jest + .spyOn(websocketStatusService, 'getConsumerStatus') + .mockReturnValue([status]) + // a mocked return value doesn't mark the zoneless view dirty, so re-create it + fixture = TestBed.createComponent(UploadStatusTrayComponent) + component = fixture.componentInstance + fixture.detectChanges() + const el: HTMLElement = fixture.nativeElement + expect(el.querySelector('.consumer-status-card')).not.toBeNull() + expect(el.textContent).toContain('invoice.pdf') + + component.toggleCollapsed() + fixture.detectChanges() + expect(el.querySelector('.consumer-status-card')).not.toBeNull() + expect(el.textContent).not.toContain('invoice.pdf') + + component.toggleCollapsed() + fixture.detectChanges() + expect(el.textContent).toContain('invoice.pdf') + }) + + it('should expand again once all statuses are dismissed', () => { + const status = websocketStatusService.newFileUpload('invoice.pdf') + fixture.detectChanges() + component.toggleCollapsed() + expect(component.collapsed()).toBe(true) + websocketStatusService.dismiss(status) + fixture.detectChanges() + expect(component.collapsed()).toBe(false) + }) + + it('should generate stats summary', () => { + mockConsumerStatuses(websocketStatusService) + expect(component.getStatusSummary()).toEqual( + 'Processing: 6, Failed: 1, Added: 4' + ) + }) + + it('should report an upload progress summary', () => { + mockConsumerStatuses(websocketStatusService) + expect(component.getTotalUploadProgress()).toEqual(0.75) + }) + + it('should change color by status phase', () => { + const processingStatus = new FileStatus() + processingStatus.phase = FileStatusPhase.WORKING + expect(component.getStatusColor(processingStatus)).toEqual('primary') + processingStatus.phase = FileStatusPhase.UPLOADING + expect(component.getStatusColor(processingStatus)).toEqual('primary') + const failedStatus = new FileStatus() + failedStatus.phase = FileStatusPhase.FAILED + expect(component.getStatusColor(failedStatus)).toEqual('danger') + const successStatus = new FileStatus() + successStatus.phase = FileStatusPhase.SUCCESS + expect(component.getStatusColor(successStatus)).toEqual('success') + }) + + it('should allow dismissing an alert', () => { + const dismissSpy = jest.spyOn(websocketStatusService, 'dismiss') + component.dismiss(new FileStatus()) + expect(dismissSpy).toHaveBeenCalled() + }) + + it('should allow dismissing completed alerts', () => { + jest.useFakeTimers() + mockConsumerStatuses(websocketStatusService) + fixture.detectChanges() + jest + .spyOn(component, 'getStatusCompleted') + .mockImplementation(() => SUCCESS_STATUSES) + const dismissSpy = jest.spyOn(websocketStatusService, 'dismiss') + component.dismissCompleted() + jest.advanceTimersByTime(1000) + fixture.detectChanges() + expect(dismissSpy).toHaveBeenCalledTimes(4) + jest.useRealTimers() + }) +}) + +function mockConsumerStatuses(consumerStatusService) { + const partialUpload1 = new FileStatus() + partialUpload1.currentPhaseProgress = 50 + partialUpload1.currentPhaseMaxProgress = 50 + const partialUpload2 = new FileStatus() + partialUpload2.currentPhaseProgress = 25 + partialUpload2.currentPhaseMaxProgress = 50 + jest + .spyOn(consumerStatusService, 'getConsumerStatus') + .mockImplementation((phase) => { + switch (phase) { + case FileStatusPhase.FAILED: + return FAILED_STATUSES + case FileStatusPhase.WORKING: + return WORKING_STATUSES + case FileStatusPhase.STARTED: + return STARTED_STATUSES + case FileStatusPhase.SUCCESS: + return SUCCESS_STATUSES + case FileStatusPhase.UPLOADING: + return [partialUpload1, partialUpload2] + default: + return DEFAULT_STATUSES + } + }) + jest + .spyOn(consumerStatusService, 'getConsumerStatusNotCompleted') + .mockImplementation(() => { + return DEFAULT_STATUSES + }) +} diff --git a/src-ui/src/app/components/common/upload-status-tray/upload-status-tray.component.ts b/src-ui/src/app/components/common/upload-status-tray/upload-status-tray.component.ts new file mode 100644 index 000000000..95549c7cf --- /dev/null +++ b/src-ui/src/app/components/common/upload-status-tray/upload-status-tray.component.ts @@ -0,0 +1,154 @@ +import { NgClass, NgTemplateOutlet } from '@angular/common' +import { Component, effect, inject, signal } from '@angular/core' +import { RouterModule } from '@angular/router' +import { + NgbAlertModule, + NgbProgressbarModule, +} from '@ng-bootstrap/ng-bootstrap' +import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' +import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component' +import { SETTINGS_KEYS } from 'src/app/data/ui-settings' +import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' +import { + PermissionAction, + PermissionsService, + PermissionType, +} from 'src/app/services/permissions.service' +import { SettingsService } from 'src/app/services/settings.service' +import { + FileStatus, + FileStatusPhase, + WebsocketStatusService, +} from 'src/app/services/websocket-status.service' + +/** Floating list of document consumption statuses, shown on every page */ +@Component({ + selector: 'pngx-upload-status-tray', + templateUrl: './upload-status-tray.component.html', + styleUrls: ['./upload-status-tray.component.scss'], + imports: [ + IfPermissionsDirective, + NgClass, + NgTemplateOutlet, + RouterModule, + NgbAlertModule, + NgbProgressbarModule, + NgxBootstrapIconsModule, + ], +}) +export class UploadStatusTrayComponent extends ComponentWithPermissions { + private websocketStatusService = inject(WebsocketStatusService) + private settingsService = inject(SettingsService) + private permissionsService = inject(PermissionsService) + + readonly collapsed = signal(false) + + constructor() { + super() + // start expanded again for the next uploads once everything is dismissed + effect(() => { + if (this.getStatus().length === 0) this.collapsed.set(false) + }) + } + + get canAddDocuments(): boolean { + return this.permissionsService.currentUserCan( + PermissionAction.Add, + PermissionType.Document + ) + } + + getStatus() { + return this.websocketStatusService.getConsumerStatus() + } + + getStatusSummary() { + let strings = [] + let countUploadingAndProcessing = + this.websocketStatusService.getConsumerStatusNotCompleted().length + let countFailed = this.getStatusFailed().length + let countSuccess = this.getStatusSuccess().length + if (countUploadingAndProcessing > 0) { + strings.push($localize`Processing: ${countUploadingAndProcessing}`) + } + if (countFailed > 0) { + strings.push($localize`Failed: ${countFailed}`) + } + if (countSuccess > 0) { + strings.push($localize`Added: ${countSuccess}`) + } + return strings.join( + $localize`:this string is used to separate processing, failed and added on the file upload widget:, ` + ) + } + + getStatusUploading() { + return this.websocketStatusService.getConsumerStatus( + FileStatusPhase.UPLOADING + ) + } + + getStatusFailed() { + return this.websocketStatusService.getConsumerStatus(FileStatusPhase.FAILED) + } + + getStatusSuccess() { + return this.websocketStatusService.getConsumerStatus( + FileStatusPhase.SUCCESS + ) + } + + getStatusCompleted() { + return this.websocketStatusService.getConsumerStatusCompleted() + } + + getTotalUploadProgress() { + let current = 0 + let max = 0 + + this.getStatusUploading().forEach((status) => { + current += status.currentPhaseProgress + max += status.currentPhaseMaxProgress + }) + + return current / Math.max(max, 1) + } + + isFinished(status: FileStatus) { + return ( + status.phase == FileStatusPhase.FAILED || + status.phase == FileStatusPhase.SUCCESS + ) + } + + getStatusColor(status: FileStatus) { + switch (status.phase) { + case FileStatusPhase.UPLOADING: + case FileStatusPhase.STARTED: + case FileStatusPhase.WORKING: + return 'primary' + case FileStatusPhase.FAILED: + return 'danger' + case FileStatusPhase.SUCCESS: + return 'success' + } + } + + toggleCollapsed() { + this.collapsed.update((collapsed) => !collapsed) + } + + dismiss(status: FileStatus) { + this.websocketStatusService.dismiss(status) + } + + dismissCompleted() { + this.getStatusCompleted().forEach((status) => + this.websocketStatusService.dismiss(status) + ) + } + + get slimSidebarEnabled(): boolean { + return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR) + } +} diff --git a/src-ui/src/app/components/dashboard/dashboard.component.spec.ts b/src-ui/src/app/components/dashboard/dashboard.component.spec.ts index 48bc7353e..77da49c0a 100644 --- a/src-ui/src/app/components/dashboard/dashboard.component.spec.ts +++ b/src-ui/src/app/components/dashboard/dashboard.component.spec.ts @@ -173,7 +173,7 @@ describe('DashboardComponent', () => { it('should update saved view sorting on drag + drop, show info', () => { const settingsSpy = jest.spyOn(settingsService, 'updateDashboardViewsSort') - const toastSpy = jest.spyOn(toastService, 'showInfo') + const toastSpy = jest.spyOn(toastService, 'showSuccess') jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true)) component.onDrop({ previousIndex: 0, currentIndex: 1 } as CdkDragDrop< SavedView[] diff --git a/src-ui/src/app/components/dashboard/dashboard.component.ts b/src-ui/src/app/components/dashboard/dashboard.component.ts index ee0902a0c..5002bf5bf 100644 --- a/src-ui/src/app/components/dashboard/dashboard.component.ts +++ b/src-ui/src/app/components/dashboard/dashboard.component.ts @@ -87,7 +87,7 @@ export class DashboardComponent extends ComponentWithPermissions { .updateDashboardViewsSort(this.dashboardViews()) .subscribe({ next: () => { - this.toastService.showInfo($localize`Dashboard updated`) + this.toastService.showSuccess($localize`Dashboard updated`) }, error: (e) => { this.toastService.showError($localize`Error updating dashboard`, e) diff --git a/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.html b/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.html index 71ac6157f..5121440a6 100644 --- a/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.html +++ b/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.html @@ -8,59 +8,5 @@ - @if (getStatus().length > 0) { -
-
-
-
-
-
- @if (getStatus().length > 0) { -

{{getStatusSummary()}}

- } - @if (getStatusCompleted().length > 0) { - - Dismiss completed - - - } -
-
-
-
- @for (status of getStatus(); track status) { -
- -
- } -
-
-
-
-
-
- }
- - - -
{{status.filename}}
- @if (!isFinished(status) || (isFinished(status) && !status.documentId)) { -

{{status.message}}

- } - -
- @if (isFinished(status)) { -
- @if (status.documentId) { - - Open document - - - } -
- } -
-
-
diff --git a/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.scss b/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.scss index 553719077..faf51eb6c 100644 --- a/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.scss +++ b/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.scss @@ -20,48 +20,6 @@ font-size: 0.75rem; } -.alert-heading { - font-size: 80%; - font-weight: bold; -} - -.alerts-hidden { - .btn { - line-height: 1; - } -} - -.btn-open { - line-height: 1; - - i-bs { - margin-top: -1px; - } -} - -.progress { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - height: auto; - mix-blend-mode: soft-light; - pointer-events: none; -} - ::ng-deep .ngx-file-drop__drop-zone--over { background-color: var(--pngx-primary-faded) !important; } - -.consumer-status-card { - background-color: rgba(var(--bs-body-bg-rgb), .95) !important; -} - -.consumer-status-list { - max-height: calc(100vh - 312px); // e.g. below the upload button, mobile - - @media screen and (min-width: 768px) { - max-height: calc(100vh - 208px); // e.g. below the upload button - } -} diff --git a/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.spec.ts b/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.spec.ts index c881149a8..7fe99bb41 100644 --- a/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.spec.ts +++ b/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.spec.ts @@ -9,35 +9,11 @@ import { routes } from 'src/app/app-routing.module' import { PermissionsGuard } from 'src/app/guards/permissions.guard' import { PermissionsService } from 'src/app/services/permissions.service' import { UploadDocumentsService } from 'src/app/services/upload-documents.service' -import { - FileStatus, - FileStatusPhase, - WebsocketStatusService, -} from 'src/app/services/websocket-status.service' import { UploadFileWidgetComponent } from './upload-file-widget.component' -const FAILED_STATUSES = [new FileStatus()] -const WORKING_STATUSES = [new FileStatus(), new FileStatus()] -const STARTED_STATUSES = [new FileStatus(), new FileStatus(), new FileStatus()] -const SUCCESS_STATUSES = [ - new FileStatus(), - new FileStatus(), - new FileStatus(), - new FileStatus(), -] -const DEFAULT_STATUSES = [ - new FileStatus(), - new FileStatus(), - new FileStatus(), - new FileStatus(), - new FileStatus(), - new FileStatus(), -] - describe('UploadFileWidgetComponent', () => { let component: UploadFileWidgetComponent let fixture: ComponentFixture - let websocketStatusService: WebsocketStatusService let uploadDocumentsService: UploadDocumentsService beforeEach(async () => { @@ -61,7 +37,6 @@ describe('UploadFileWidgetComponent', () => { ], }).compileComponents() - websocketStatusService = TestBed.inject(WebsocketStatusService) uploadDocumentsService = TestBed.inject(UploadDocumentsService) fixture = TestBed.createComponent(UploadFileWidgetComponent) component = fixture.componentInstance @@ -91,83 +66,4 @@ describe('UploadFileWidgetComponent', () => { fileInput.nativeElement.dispatchEvent(new Event('change')) expect(uploadSpy).toHaveBeenCalledWith(file) }) - - it('should generate stats summary', () => { - mockConsumerStatuses(websocketStatusService) - expect(component.getStatusSummary()).toEqual( - 'Processing: 6, Failed: 1, Added: 4' - ) - }) - - it('should report an upload progress summary', () => { - mockConsumerStatuses(websocketStatusService) - expect(component.getTotalUploadProgress()).toEqual(0.75) - }) - - it('should change color by status phase', () => { - const processingStatus = new FileStatus() - processingStatus.phase = FileStatusPhase.WORKING - expect(component.getStatusColor(processingStatus)).toEqual('primary') - processingStatus.phase = FileStatusPhase.UPLOADING - expect(component.getStatusColor(processingStatus)).toEqual('primary') - const failedStatus = new FileStatus() - failedStatus.phase = FileStatusPhase.FAILED - expect(component.getStatusColor(failedStatus)).toEqual('danger') - const successStatus = new FileStatus() - successStatus.phase = FileStatusPhase.SUCCESS - expect(component.getStatusColor(successStatus)).toEqual('success') - }) - - it('should allow dismissing an alert', () => { - const dismissSpy = jest.spyOn(websocketStatusService, 'dismiss') - component.dismiss(new FileStatus()) - expect(dismissSpy).toHaveBeenCalled() - }) - - it('should allow dismissing completed alerts', () => { - jest.useFakeTimers() - mockConsumerStatuses(websocketStatusService) - fixture.detectChanges() - jest - .spyOn(component, 'getStatusCompleted') - .mockImplementation(() => SUCCESS_STATUSES) - const dismissSpy = jest.spyOn(websocketStatusService, 'dismiss') - component.dismissCompleted() - jest.advanceTimersByTime(1000) - fixture.detectChanges() - expect(dismissSpy).toHaveBeenCalledTimes(4) - jest.useRealTimers() - }) }) - -function mockConsumerStatuses(consumerStatusService) { - const partialUpload1 = new FileStatus() - partialUpload1.currentPhaseProgress = 50 - partialUpload1.currentPhaseMaxProgress = 50 - const partialUpload2 = new FileStatus() - partialUpload2.currentPhaseProgress = 25 - partialUpload2.currentPhaseMaxProgress = 50 - jest - .spyOn(consumerStatusService, 'getConsumerStatus') - .mockImplementation((phase) => { - switch (phase) { - case FileStatusPhase.FAILED: - return FAILED_STATUSES - case FileStatusPhase.WORKING: - return WORKING_STATUSES - case FileStatusPhase.STARTED: - return STARTED_STATUSES - case FileStatusPhase.SUCCESS: - return SUCCESS_STATUSES - case FileStatusPhase.UPLOADING: - return [partialUpload1, partialUpload2] - default: - return DEFAULT_STATUSES - } - }) - jest - .spyOn(consumerStatusService, 'getConsumerStatusNotCompleted') - .mockImplementation(() => { - return DEFAULT_STATUSES - }) -} diff --git a/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.ts b/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.ts index 7d3f402aa..550701edf 100644 --- a/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.ts +++ b/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.ts @@ -1,23 +1,9 @@ -import { NgClass, NgTemplateOutlet } from '@angular/common' -import { Component, QueryList, ViewChildren, inject } from '@angular/core' -import { RouterModule } from '@angular/router' -import { - NgbAlert, - NgbAlertModule, - NgbProgressbarModule, -} from '@ng-bootstrap/ng-bootstrap' +import { Component, inject } from '@angular/core' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap' import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component' -import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' -import { SettingsService } from 'src/app/services/settings.service' import { UploadDocumentsService } from 'src/app/services/upload-documents.service' -import { - FileStatus, - FileStatusPhase, - WebsocketStatusService, -} from 'src/app/services/websocket-status.service' import { WidgetFrameComponent } from '../widget-frame/widget-frame.component' @Component({ @@ -27,115 +13,16 @@ import { WidgetFrameComponent } from '../widget-frame/widget-frame.component' imports: [ WidgetFrameComponent, IfPermissionsDirective, - NgClass, - NgTemplateOutlet, - RouterModule, - NgbAlertModule, - NgbProgressbarModule, NgxBootstrapIconsModule, TourNgBootstrap, ], }) export class UploadFileWidgetComponent extends ComponentWithPermissions { - private websocketStatusService = inject(WebsocketStatusService) private uploadDocumentsService = inject(UploadDocumentsService) - settingsService = inject(SettingsService) - - @ViewChildren(NgbAlert) alerts: QueryList - - getStatus() { - return this.websocketStatusService.getConsumerStatus() - } - - getStatusSummary() { - let strings = [] - let countUploadingAndProcessing = - this.websocketStatusService.getConsumerStatusNotCompleted().length - let countFailed = this.getStatusFailed().length - let countSuccess = this.getStatusSuccess().length - if (countUploadingAndProcessing > 0) { - strings.push($localize`Processing: ${countUploadingAndProcessing}`) - } - if (countFailed > 0) { - strings.push($localize`Failed: ${countFailed}`) - } - if (countSuccess > 0) { - strings.push($localize`Added: ${countSuccess}`) - } - return strings.join( - $localize`:this string is used to separate processing, failed and added on the file upload widget:, ` - ) - } - - getStatusUploading() { - return this.websocketStatusService.getConsumerStatus( - FileStatusPhase.UPLOADING - ) - } - - getStatusFailed() { - return this.websocketStatusService.getConsumerStatus(FileStatusPhase.FAILED) - } - - getStatusSuccess() { - return this.websocketStatusService.getConsumerStatus( - FileStatusPhase.SUCCESS - ) - } - - getStatusCompleted() { - return this.websocketStatusService.getConsumerStatusCompleted() - } - - getTotalUploadProgress() { - let current = 0 - let max = 0 - - this.getStatusUploading().forEach((status) => { - current += status.currentPhaseProgress - max += status.currentPhaseMaxProgress - }) - - return current / Math.max(max, 1) - } - - isFinished(status: FileStatus) { - return ( - status.phase == FileStatusPhase.FAILED || - status.phase == FileStatusPhase.SUCCESS - ) - } - - getStatusColor(status: FileStatus) { - switch (status.phase) { - case FileStatusPhase.UPLOADING: - case FileStatusPhase.STARTED: - case FileStatusPhase.WORKING: - return 'primary' - case FileStatusPhase.FAILED: - return 'danger' - case FileStatusPhase.SUCCESS: - return 'success' - } - } - - dismiss(status: FileStatus) { - this.websocketStatusService.dismiss(status) - } - - dismissCompleted() { - this.getStatusCompleted().forEach((status) => - this.websocketStatusService.dismiss(status) - ) - } public onFileSelected(event: Event) { const input = event.target as HTMLInputElement this.uploadDocumentsService.uploadFiles(Array.from(input.files ?? [])) input.value = '' } - - get slimSidebarEnabled(): boolean { - return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR) - } } diff --git a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts index 13bba3f7b..c89c9b89f 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts @@ -60,6 +60,7 @@ import { TagService } from 'src/app/services/rest/tag.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 { TrashService } from 'src/app/services/trash.service' import { WebsocketStatusService } from 'src/app/services/websocket-status.service' import { environment } from 'src/environments/environment' import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component' @@ -137,6 +138,7 @@ describe('DocumentDetailComponent', () => { let openDocumentsService: OpenDocumentsService let modalService: NgbModal let toastService: ToastService + let trashService: TrashService let documentListViewService: DocumentListViewService let settingsService: SettingsService let customFieldsService: CustomFieldsService @@ -285,6 +287,7 @@ describe('DocumentDetailComponent', () => { documentService = TestBed.inject(DocumentService) modalService = TestBed.inject(NgbModal) toastService = TestBed.inject(ToastService) + trashService = TestBed.inject(TrashService) documentListViewService = TestBed.inject(DocumentListViewService) settingsService = TestBed.inject(SettingsService) settingsService.currentUser.set({ id: 1 }) @@ -775,7 +778,7 @@ describe('DocumentDetailComponent', () => { component.title.set('Foo Bar') const closeSpy = jest.spyOn(component, 'close') const patchSpy = jest.spyOn(documentService, 'patch') - const toastSpy = jest.spyOn(toastService, 'showInfo') + const toastSpy = jest.spyOn(toastService, 'showSuccess') patchSpy.mockImplementation((o) => of(doc)) component.save(true) expect(patchSpy).toHaveBeenCalled() @@ -790,7 +793,7 @@ describe('DocumentDetailComponent', () => { component.title.set('Foo Bar') const closeSpy = jest.spyOn(component, 'close') const patchSpy = jest.spyOn(documentService, 'patch') - const toastSpy = jest.spyOn(toastService, 'showInfo') + const toastSpy = jest.spyOn(toastService, 'showSuccess') patchSpy.mockImplementation((o) => of(doc)) component.save() expect(patchSpy).toHaveBeenCalled() @@ -836,7 +839,7 @@ describe('DocumentDetailComponent', () => { component.title.set('Foo Bar') const closeSpy = jest.spyOn(component, 'close') const patchSpy = jest.spyOn(documentService, 'patch') - const toastSpy = jest.spyOn(toastService, 'showInfo') + const toastSpy = jest.spyOn(toastService, 'showSuccess') patchSpy.mockImplementation(() => throwError(() => new Error('failed to save')) ) @@ -957,12 +960,16 @@ describe('DocumentDetailComponent', () => { const modalSpy = jest.spyOn(modalService, 'open') const deleteSpy = jest.spyOn(documentService, 'delete') deleteSpy.mockReturnValue(of(true)) + const notifySpy = jest + .spyOn(trashService, 'notifyMovedToTrash') + .mockImplementation(() => {}) component.delete() expect(modalSpy).toHaveBeenCalled() const modalCloseSpy = jest.spyOn(openModal, 'close') openModal.componentInstance.confirmClicked.next() expect(deleteSpy).toHaveBeenCalled() expect(modalCloseSpy).toHaveBeenCalled() + expect(notifySpy).toHaveBeenCalledWith(expect.any(String), [doc.id]) }) it('should allow retry delete if error', () => { diff --git a/src-ui/src/app/components/document-detail/document-detail.component.ts b/src-ui/src/app/components/document-detail/document-detail.component.ts index 02d461ed6..8b1e7da9d 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.ts @@ -95,6 +95,7 @@ import { TagService } from 'src/app/services/rest/tag.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 { TrashService } from 'src/app/services/trash.service' import { WebsocketStatusService } from 'src/app/services/websocket-status.service' import { getFilenameFromContentDisposition } from 'src/app/utils/http' import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter' @@ -221,6 +222,7 @@ export class DocumentDetailComponent private documentListViewService = inject(DocumentListViewService) private documentTitlePipe = inject(DocumentTitlePipe) private toastService = inject(ToastService) + private trashService = inject(TrashService) private settings = inject(SettingsService) private storagePathService = inject(StoragePathService) private permissionsService = inject(PermissionsService) @@ -1349,7 +1351,7 @@ export class DocumentDetailComponent this.store.next(newValues) this.openDocumentService.setDirty(this.document(), false) this.openDocumentService.save() - this.toastService.showInfo( + this.toastService.showSuccess( $localize`Document "${newValues.title}" saved successfully.` ) this.networkActive.set(false) @@ -1377,7 +1379,7 @@ export class DocumentDetailComponent if (!canEdit) { // document was 'given away' this.openDocumentService.setDirty(this.document(), false) - this.toastService.showInfo( + this.toastService.showSuccess( $localize`Document "${this.document().title}" saved successfully.` ) this.close() @@ -1490,6 +1492,10 @@ export class DocumentDetailComponent .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe({ next: () => { + this.trashService.notifyMovedToTrash( + $localize`Moved "${this.document().title}" to the trash`, + [this.document().id] + ) modal.close() this.close() }, diff --git a/src-ui/src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.spec.ts b/src-ui/src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.spec.ts index f0e30197f..15c813c4e 100644 --- a/src-ui/src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.spec.ts +++ b/src-ui/src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.spec.ts @@ -33,7 +33,9 @@ describe('DocumentVersionDropdownComponent', () => { | 'updateVersionLabel' > > - let toastService: jest.Mocked> + let toastService: jest.Mocked< + Pick + > let finished$: Subject<{ taskId: string }> let failed$: Subject<{ taskId: string; message?: string }> let modalService: jest.Mocked> @@ -55,6 +57,7 @@ describe('DocumentVersionDropdownComponent', () => { toastService = { showError: jest.fn(), showInfo: jest.fn(), + showSuccess: jest.fn(), } permissionsService = { currentUserCan: jest.fn().mockReturnValue(true), @@ -416,6 +419,6 @@ describe('DocumentVersionDropdownComponent', () => { expect(selectedEmitSpy).toHaveBeenCalledWith(2) expect(component.newVersionLabel).toEqual('') expect(modal.close).toHaveBeenCalled() - expect(toastService.showInfo).toHaveBeenCalled() + expect(toastService.showSuccess).toHaveBeenCalled() }) }) diff --git a/src-ui/src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts b/src-ui/src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts index a341cfa16..b408d7d1d 100644 --- a/src-ui/src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts +++ b/src-ui/src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts @@ -320,7 +320,7 @@ export class DocumentVersionDropdownComponent } this.newVersionLabel = '' modal.close() - this.toastService.showInfo( + this.toastService.showSuccess( $localize`Existing document added as a version.` ) }, diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts index e8d38c11b..b80c886f9 100644 --- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts +++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts @@ -39,6 +39,7 @@ import { TagService } from 'src/app/services/rest/tag.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 { TrashService } from 'src/app/services/trash.service' import { environment } from 'src/environments/environment' import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component' import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component' @@ -74,6 +75,7 @@ describe('BulkEditorComponent', () => { let documentListViewService: DocumentListViewService let documentService: DocumentService let toastService: ToastService + let trashService: TrashService let modalService: NgbModal let tagService: TagService let correspondentsService: CorrespondentService @@ -185,6 +187,7 @@ describe('BulkEditorComponent', () => { documentListViewService = TestBed.inject(DocumentListViewService) documentService = TestBed.inject(DocumentService) toastService = TestBed.inject(ToastService) + trashService = TestBed.inject(TrashService) modalService = TestBed.inject(NgbModal) tagService = TestBed.inject(TagService) correspondentsService = TestBed.inject(CorrespondentService) @@ -1164,10 +1167,14 @@ describe('BulkEditorComponent', () => { let req = httpTestingController.expectOne( `${environment.apiBaseUrl}documents/delete/` ) + const notifySpy = jest + .spyOn(trashService, 'notifyMovedToTrash') + .mockImplementation(() => {}) req.flush(true) expect(req.request.body).toEqual({ documents: [3, 4], }) + expect(notifySpy).toHaveBeenCalledWith(expect.any(String), [3, 4]) httpTestingController.match( `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` ) // list reload @@ -1177,10 +1184,19 @@ describe('BulkEditorComponent', () => { component.showConfirmationDialogs = false fixture.detectChanges() + jest + .spyOn(documentListViewService, 'allSelected', 'get') + .mockReturnValue(true) component.applyDelete() req = httpTestingController.expectOne( `${environment.apiBaseUrl}documents/delete/` ) + req.flush(true) + // ids aren't known when all matching documents are selected, so no undo + expect(notifySpy).toHaveBeenLastCalledWith(expect.any(String), null) + httpTestingController.match( + `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` + ) // list reload }) it('should not be accessible with insufficient global permissions', () => { @@ -1416,7 +1432,7 @@ describe('BulkEditorComponent', () => { const mergeAsVersionsSpy = jest .spyOn(documentService, 'mergeDocumentsAsVersions') .mockReturnValue(of(true)) - const toastInfoSpy = jest.spyOn(toastService, 'showInfo') + const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess') fixture.detectChanges() component.mergeSelectedAsVersions() @@ -1432,7 +1448,9 @@ describe('BulkEditorComponent', () => { `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` ) expect(documentListViewService.selected.size).toEqual(0) - expect(toastInfoSpy).toHaveBeenCalledWith('Documents merged as versions.') + expect(toastSuccessSpy).toHaveBeenCalledWith( + 'Documents merged as versions.' + ) }) it('should not report success when merging documents as versions fails', () => { @@ -1458,7 +1476,7 @@ describe('BulkEditorComponent', () => { jest .spyOn(documentService, 'mergeDocumentsAsVersions') .mockReturnValue(throwError(() => new Error('failed'))) - const toastInfoSpy = jest.spyOn(toastService, 'showInfo') + const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess') const toastErrorSpy = jest.spyOn(toastService, 'showError') fixture.detectChanges() @@ -1467,7 +1485,7 @@ describe('BulkEditorComponent', () => { modal.componentInstance.confirm() expect(toastErrorSpy).toHaveBeenCalled() - expect(toastInfoSpy).not.toHaveBeenCalled() + expect(toastSuccessSpy).not.toHaveBeenCalled() }) it('should support bulk download with archive, originals or both and file formatting', () => { @@ -1846,7 +1864,7 @@ describe('BulkEditorComponent', () => { .spyOn(documentListViewService, 'selected', 'get') .mockReturnValue(new Set([3, 4])) fixture.detectChanges() - const toastServiceShowInfoSpy = jest.spyOn(toastService, 'showInfo') + const toastServiceShowInfoSpy = jest.spyOn(toastService, 'showSuccess') const toastServiceShowErrorSpy = jest.spyOn(toastService, 'showError') const listReloadSpy = jest.spyOn(documentListViewService, 'reload') diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts index 33181a227..5600c977a 100644 --- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts +++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -20,7 +20,15 @@ import { } from '@ng-bootstrap/ng-bootstrap' import { saveAs } from 'file-saver' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' -import { first, map, Observable, Subject, switchMap, takeUntil } from 'rxjs' +import { + first, + map, + Observable, + Subject, + switchMap, + takeUntil, + tap, +} from 'rxjs' import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component' import { CustomField } from 'src/app/data/custom-field' import { MatchingModel } from 'src/app/data/matching-model' @@ -49,6 +57,7 @@ import { StoragePathService } from 'src/app/services/rest/storage-path.service' import { TagService } from 'src/app/services/rest/tag.service' import { SettingsService } from 'src/app/services/settings.service' import { ToastService } from 'src/app/services/toast.service' +import { TrashService } from 'src/app/services/trash.service' import { flattenTags } from 'src/app/utils/flatten-tags' import { queryParamsFromFilterRules } from 'src/app/utils/query-params' import { MergeAsVersionsConfirmDialogComponent } from '../../common/confirm-dialog/merge-as-versions-confirm-dialog/merge-as-versions-confirm-dialog.component' @@ -99,6 +108,7 @@ export class BulkEditorComponent private openDocumentService = inject(OpenDocumentsService) private settings = inject(SettingsService) private toastService = inject(ToastService) + private trashService = inject(TrashService) private storagePathService = inject(StoragePathService) private customFieldService = inject(CustomFieldsService) public readonly permissionService = inject(PermissionsService) @@ -306,7 +316,7 @@ export class BulkEditorComponent next: () => { this.handleOperationSuccess(modal, options.clearSelection ?? false) if (options.successMessage) { - this.toastService.showInfo(options.successMessage) + this.toastService.showSuccess(options.successMessage) } }, error: (error) => this.handleOperationError(modal, error), @@ -884,19 +894,33 @@ export class BulkEditorComponent .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe(() => { modal.componentInstance.buttonsEnabled.set(false) - this.executeDocumentAction( - modal, - this.documentService.deleteDocuments(this.getSelectionQuery()) - ) + this.deleteSelected(modal) }) } else { - this.executeDocumentAction( - null, - this.documentService.deleteDocuments(this.getSelectionQuery()) - ) + this.deleteSelected(null) } } + private deleteSelected(modal: NgbModalRef) { + const documents = this.list.allSelected + ? null + : Array.from(this.list.selected) + const count = this.getSelectionSize() + this.executeDocumentAction( + modal, + this.documentService + .deleteDocuments(this.getSelectionQuery()) + .pipe( + tap(() => + this.trashService.notifyMovedToTrash( + $localize`Moved ${count} document(s) to the trash`, + documents + ) + ) + ) + ) + } + downloadSelected() { this.awaitingDownload.set(true) let downloadFileType: string = @@ -1065,7 +1089,7 @@ export class BulkEditorComponent dialog.selection = this.getSelectionQuery() dialog.selectionCount = this.getSelectionSize() dialog.succeeded.subscribe((result) => { - this.toastService.showInfo($localize`Custom fields updated.`) + this.toastService.showSuccess($localize`Custom fields updated.`) this.list.reload() this.list.reduceSelectionToFilter() this.list.selected.forEach((id) => { diff --git a/src-ui/src/app/components/document-list/document-list.component.spec.ts b/src-ui/src/app/components/document-list/document-list.component.spec.ts index 798abc1c8..202e8f9ac 100644 --- a/src-ui/src/app/components/document-list/document-list.component.spec.ts +++ b/src-ui/src/app/components/document-list/document-list.component.spec.ts @@ -487,7 +487,7 @@ describe('DocumentListComponent', () => { delete modifiedView.name const savedViewServicePatch = jest.spyOn(savedViewService, 'patch') savedViewServicePatch.mockReturnValue(of(modifiedView)) - const toastSpy = jest.spyOn(toastService, 'showInfo') + const toastSpy = jest.spyOn(toastService, 'showSuccess') component.saveViewConfig() expect(savedViewServicePatch).toHaveBeenCalledWith(modifiedView) @@ -592,7 +592,7 @@ describe('DocumentListComponent', () => { let openModal: NgbModalRef modalService.activeInstances.subscribe((modal) => (openModal = modal[0])) const modalSpy = jest.spyOn(modalService, 'open') - const toastSpy = jest.spyOn(toastService, 'showInfo') + const toastSpy = jest.spyOn(toastService, 'showSuccess') const savedViewServiceCreate = jest.spyOn(savedViewService, 'create') jest .spyOn(savedViewService, 'dashboardViews', 'get') diff --git a/src-ui/src/app/components/document-list/document-list.component.ts b/src-ui/src/app/components/document-list/document-list.component.ts index 48e4841f3..739a1c8a3 100644 --- a/src-ui/src/app/components/document-list/document-list.component.ts +++ b/src-ui/src/app/components/document-list/document-list.component.ts @@ -418,7 +418,7 @@ export class DocumentListComponent next: (view) => { this.activeSavedView = view this.unmodifiedSavedView = view - this.toastService.showInfo( + this.toastService.showSuccess( $localize`View "${this.list.activeSavedViewTitle}" saved successfully.` ) this.unmodifiedFilterRules.set(this.list.filterRules) @@ -493,7 +493,7 @@ export class DocumentListComponent .subscribe({ next: () => { modal.close() - this.toastService.showInfo( + this.toastService.showSuccess( $localize`View "${savedView.name}" created successfully.` ) }, diff --git a/src-ui/src/app/components/manage/document-attributes/custom-fields/custom-fields.component.spec.ts b/src-ui/src/app/components/manage/document-attributes/custom-fields/custom-fields.component.spec.ts index 8910b249b..4fbd77be0 100644 --- a/src-ui/src/app/components/manage/document-attributes/custom-fields/custom-fields.component.spec.ts +++ b/src-ui/src/app/components/manage/document-attributes/custom-fields/custom-fields.component.spec.ts @@ -107,7 +107,7 @@ describe('CustomFieldsComponent', () => { 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 toastInfoSpy = jest.spyOn(toastService, 'showSuccess') const reloadSpy = jest.spyOn(component, 'reload') component.editField() @@ -131,7 +131,7 @@ describe('CustomFieldsComponent', () => { 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 toastInfoSpy = jest.spyOn(toastService, 'showSuccess') const reloadSpy = jest.spyOn(component, 'reload') const editButton = fixture.debugElement diff --git a/src-ui/src/app/components/manage/document-attributes/custom-fields/custom-fields.component.ts b/src-ui/src/app/components/manage/document-attributes/custom-fields/custom-fields.component.ts index 44e02a99c..5d861acef 100644 --- a/src-ui/src/app/components/manage/document-attributes/custom-fields/custom-fields.component.ts +++ b/src-ui/src/app/components/manage/document-attributes/custom-fields/custom-fields.component.ts @@ -82,7 +82,9 @@ export class CustomFieldsComponent modal.componentInstance.succeeded .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe((newField) => { - this.toastService.showInfo($localize`Saved field "${newField.name}".`) + this.toastService.showSuccess( + $localize`Saved field "${newField.name}".` + ) this.customFieldsService.clearCache() this.settingsService.initializeDisplayFields() this.documentService.reload() @@ -109,7 +111,9 @@ export class CustomFieldsComponent this.customFieldsService.delete(field).subscribe({ next: () => { modal.close() - this.toastService.showInfo($localize`Deleted field "${field.name}"`) + this.toastService.showSuccess( + $localize`Deleted field "${field.name}"` + ) this.customFieldsService.clearCache() this.settingsService.initializeDisplayFields() this.documentService.reload() diff --git a/src-ui/src/app/components/manage/document-attributes/management-list/management-list.component.spec.ts b/src-ui/src/app/components/manage/document-attributes/management-list/management-list.component.spec.ts index 7f4ff1dc1..4e2d17321 100644 --- a/src-ui/src/app/components/manage/document-attributes/management-list/management-list.component.spec.ts +++ b/src-ui/src/app/components/manage/document-attributes/management-list/management-list.component.spec.ts @@ -160,7 +160,7 @@ describe('ManagementListComponent', () => { 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 toastInfoSpy = jest.spyOn(toastService, 'showSuccess') const reloadSpy = jest.spyOn(component, 'reloadData') component.openCreateDialog() @@ -183,7 +183,7 @@ describe('ManagementListComponent', () => { 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 toastInfoSpy = jest.spyOn(toastService, 'showSuccess') const reloadSpy = jest.spyOn(component, 'reloadData') component.openEditDialog(tags[0]) @@ -387,7 +387,7 @@ describe('ManagementListComponent', () => { expect(bulkEditPermsSpy).toHaveBeenCalled() expect(errorToastSpy).toHaveBeenCalled() - const successToastSpy = jest.spyOn(toastService, 'showInfo') + const successToastSpy = jest.spyOn(toastService, 'showSuccess') bulkEditPermsSpy.mockReturnValueOnce(of('OK')) modal.componentInstance.confirmClicked.emit({ permissions: {}, @@ -452,7 +452,7 @@ describe('ManagementListComponent', () => { ) expect(errorToastSpy).toHaveBeenCalled() - const successToastSpy = jest.spyOn(toastService, 'showInfo') + const successToastSpy = jest.spyOn(toastService, 'showSuccess') bulkEditSpy.mockReturnValueOnce(of('OK')) modal.componentInstance.confirmClicked.emit(null) expect(bulkEditSpy).toHaveBeenCalled() diff --git a/src-ui/src/app/components/manage/document-attributes/management-list/management-list.component.ts b/src-ui/src/app/components/manage/document-attributes/management-list/management-list.component.ts index 6b58fdd87..dd102668e 100644 --- a/src-ui/src/app/components/manage/document-attributes/management-list/management-list.component.ts +++ b/src-ui/src/app/components/manage/document-attributes/management-list/management-list.component.ts @@ -224,7 +224,7 @@ export abstract class ManagementListComponent activeModal.componentInstance.dialogMode.set(EditDialogMode.CREATE) activeModal.componentInstance.succeeded.subscribe(() => { this.reloadData() - this.toastService.showInfo( + this.toastService.showSuccess( $localize`Successfully created ${this.typeName}.` ) }) @@ -244,7 +244,7 @@ export abstract class ManagementListComponent activeModal.componentInstance.dialogMode.set(EditDialogMode.EDIT) activeModal.componentInstance.succeeded.subscribe(() => { this.reloadData() - this.toastService.showInfo( + this.toastService.showSuccess( $localize`Successfully updated ${this.typeName} "${object.name}".` ) }) @@ -428,7 +428,7 @@ export abstract class ManagementListComponent .subscribe({ next: () => { modal.close() - this.toastService.showInfo( + this.toastService.showSuccess( $localize`Permissions updated successfully` ) this.reloadData() @@ -468,7 +468,9 @@ export abstract class ManagementListComponent .subscribe({ next: () => { modal.close() - this.toastService.showInfo($localize`Objects deleted successfully`) + this.toastService.showSuccess( + $localize`Objects deleted successfully` + ) this.reloadData() }, error: (error) => { diff --git a/src-ui/src/app/components/manage/mail/mail.component.spec.ts b/src-ui/src/app/components/manage/mail/mail.component.spec.ts index cb2111fcd..b3b3fa119 100644 --- a/src-ui/src/app/components/manage/mail/mail.component.spec.ts +++ b/src-ui/src/app/components/manage/mail/mail.component.spec.ts @@ -184,7 +184,7 @@ describe('MailComponent', () => { component.editMailAccount(mailAccounts[0] as MailAccount) let editDialog = modal.componentInstance as MailAccountEditDialogComponent const toastErrorSpy = jest.spyOn(toastService, 'showError') - const toastInfoSpy = jest.spyOn(toastService, 'showInfo') + const toastInfoSpy = jest.spyOn(toastService, 'showSuccess') editDialog.failed.emit() expect(toastErrorSpy).toHaveBeenCalled() editDialog.succeeded.emit(mailAccounts[0] as any) @@ -203,7 +203,7 @@ describe('MailComponent', () => { const deleteDialog = modal.componentInstance as ConfirmDialogComponent const deleteSpy = jest.spyOn(mailAccountService, 'delete') const toastErrorSpy = jest.spyOn(toastService, 'showError') - const toastInfoSpy = jest.spyOn(toastService, 'showInfo') + const toastInfoSpy = jest.spyOn(toastService, 'showSuccess') const listAllSpy = jest.spyOn(mailAccountService, 'listAll') deleteSpy.mockReturnValueOnce( throwError(() => new Error('error deleting mail account')) @@ -242,7 +242,7 @@ describe('MailComponent', () => { component.editMailRule(mailRules[0] as MailRule) const editDialog = modal.componentInstance as MailRuleEditDialogComponent const toastErrorSpy = jest.spyOn(toastService, 'showError') - const toastInfoSpy = jest.spyOn(toastService, 'showInfo') + const toastInfoSpy = jest.spyOn(toastService, 'showSuccess') editDialog.failed.emit() expect(toastErrorSpy).toHaveBeenCalled() editDialog.succeeded.emit(mailRules[0] as any) @@ -272,7 +272,7 @@ describe('MailComponent', () => { const deleteDialog = modal.componentInstance as ConfirmDialogComponent const deleteSpy = jest.spyOn(mailRuleService, 'delete') const toastErrorSpy = jest.spyOn(toastService, 'showError') - const toastInfoSpy = jest.spyOn(toastService, 'showInfo') + const toastInfoSpy = jest.spyOn(toastService, 'showSuccess') const listAllSpy = jest.spyOn(mailRuleService, 'listAll') deleteSpy.mockReturnValueOnce( throwError(() => new Error('error deleting mail rule "rule1"')) @@ -303,7 +303,7 @@ describe('MailComponent', () => { let modal: NgbModalRef modalService.activeInstances.subscribe((refs) => (modal = refs[0])) const toastErrorSpy = jest.spyOn(toastService, 'showError') - const toastInfoSpy = jest.spyOn(toastService, 'showInfo') + const toastInfoSpy = jest.spyOn(toastService, 'showSuccess') const rulePatchSpy = jest.spyOn(mailRuleService, 'patch') component.editPermissions(mailRules[0] as MailRule) expect(modal).not.toBeUndefined() @@ -356,7 +356,7 @@ describe('MailComponent', () => { By.css('input[type="checkbox"]') ) const toastErrorSpy = jest.spyOn(toastService, 'showError') - const toastInfoSpy = jest.spyOn(toastService, 'showInfo') + const toastInfoSpy = jest.spyOn(toastService, 'showSuccess') // fail first patchSpy.mockReturnValueOnce( throwError(() => new Error('Error getting config')) @@ -380,7 +380,7 @@ describe('MailComponent', () => { jest .spyOn(activatedRoute, 'queryParamMap', 'get') .mockReturnValue(of(convertToParamMap(queryParams))) - const toastInfoSpy = jest.spyOn(toastService, 'showInfo') + const toastInfoSpy = jest.spyOn(toastService, 'showSuccess') completeSetup() expect(toastInfoSpy).toHaveBeenCalled() }) diff --git a/src-ui/src/app/components/manage/mail/mail.component.ts b/src-ui/src/app/components/manage/mail/mail.component.ts index 2989d1763..483f387de 100644 --- a/src-ui/src/app/components/manage/mail/mail.component.ts +++ b/src-ui/src/app/components/manage/mail/mail.component.ts @@ -135,7 +135,9 @@ export class MailComponent if (params.get('oauth_success')) { const success = params.get('oauth_success') === '1' if (success) { - this.toastService.showInfo($localize`OAuth2 authentication success`) + this.toastService.showSuccess( + $localize`OAuth2 authentication success` + ) this.oAuthAccountId = parseInt(params.get('account_id')) if (this.mailAccounts().length > 0) { this.editMailAccount( @@ -169,7 +171,7 @@ export class MailComponent modal.componentInstance.succeeded .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe((newMailAccount) => { - this.toastService.showInfo( + this.toastService.showSuccess( $localize`Saved account "${newMailAccount.name}".` ) this.mailAccountService.clearCache() @@ -200,7 +202,7 @@ export class MailComponent this.mailAccountService.delete(account).subscribe({ next: () => { modal.close() - this.toastService.showInfo( + this.toastService.showSuccess( $localize`Deleted mail account "${account.name}"` ) this.mailAccountService.clearCache() @@ -248,7 +250,9 @@ export class MailComponent modal.componentInstance.succeeded .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe((newMailRule) => { - this.toastService.showInfo($localize`Saved rule "${newMailRule.name}".`) + this.toastService.showSuccess( + $localize`Saved rule "${newMailRule.name}".` + ) this.mailRuleService.clearCache() this.mailRuleService .listAll(null, null, { full_perms: true }) @@ -273,7 +277,7 @@ export class MailComponent onMailRuleEnableToggled(rule: MailRule) { this.mailRuleService.patch(rule).subscribe({ next: () => { - this.toastService.showInfo( + this.toastService.showSuccess( rule.enabled ? $localize`Rule "${rule.name}" enabled.` : $localize`Rule "${rule.name}" disabled.` @@ -302,7 +306,7 @@ export class MailComponent this.mailRuleService.delete(rule).subscribe({ next: () => { modal.close() - this.toastService.showInfo( + this.toastService.showSuccess( $localize`Deleted mail rule "${rule.name}"` ) this.mailRuleService.clearCache() @@ -338,7 +342,7 @@ export class MailComponent object['set_permissions'] = permissions['set_permissions'] service.patch(object).subscribe({ next: () => { - this.toastService.showInfo($localize`Permissions updated`) + this.toastService.showSuccess($localize`Permissions updated`) modal.close() }, error: (e) => { diff --git a/src-ui/src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.spec.ts b/src-ui/src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.spec.ts index 50f646671..13da4e784 100644 --- a/src-ui/src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.spec.ts +++ b/src-ui/src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.spec.ts @@ -96,7 +96,7 @@ describe('ProcessedMailDialogComponent', () => { // select a couple of mails and delete component.selectedMailIds().add(5) component.selectedMailIds().add(6) - const toastInfoSpy = jest.spyOn(toastService, 'showInfo') + const toastInfoSpy = jest.spyOn(toastService, 'showSuccess') component.deleteSelected() const delReq = httpTestingController.expectOne( diff --git a/src-ui/src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.ts b/src-ui/src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.ts index 32db2eb26..867fe3aeb 100644 --- a/src-ui/src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.ts +++ b/src-ui/src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.ts @@ -67,7 +67,7 @@ export class ProcessedMailDialogComponent implements OnInit { this.processedMailService .bulk_delete(Array.from(this.selectedMailIds())) .subscribe(() => { - this.toastService.showInfo($localize`Processed mail(s) deleted`) + this.toastService.showSuccess($localize`Processed mail(s) deleted`) this.loadProcessedMails() }) } diff --git a/src-ui/src/app/components/manage/saved-views/saved-views.component.spec.ts b/src-ui/src/app/components/manage/saved-views/saved-views.component.spec.ts index 05d950366..9c3fb406e 100644 --- a/src-ui/src/app/components/manage/saved-views/saved-views.component.spec.ts +++ b/src-ui/src/app/components/manage/saved-views/saved-views.component.spec.ts @@ -227,7 +227,7 @@ describe('SavedViewsComponent', () => { }) it('should support delete saved view', () => { - const toastSpy = jest.spyOn(toastService, 'showInfo') + const toastSpy = jest.spyOn(toastService, 'showSuccess') const deleteSpy = jest.spyOn(savedViewService, 'delete') deleteSpy.mockReturnValue(of(true)) component.deleteSavedView(savedViews[0] as SavedView) diff --git a/src-ui/src/app/components/manage/saved-views/saved-views.component.ts b/src-ui/src/app/components/manage/saved-views/saved-views.component.ts index 0622fc399..c69711b71 100644 --- a/src-ui/src/app/components/manage/saved-views/saved-views.component.ts +++ b/src-ui/src/app/components/manage/saved-views/saved-views.component.ts @@ -183,7 +183,7 @@ export class SavedViewsComponent this.savedViews.update((savedViews) => savedViews.filter((view) => view !== savedView) ) - this.toastService.showInfo( + this.toastService.showSuccess( $localize`Saved view "${savedView.name}" deleted.` ) this.savedViewService.clearCache() @@ -262,7 +262,7 @@ export class SavedViewsComponent saveOperation.subscribe({ next: () => { - this.toastService.showInfo($localize`Views saved successfully.`) + this.toastService.showSuccess($localize`Views saved successfully.`) this.savedViewService.clearCache() this.reloadViews() }, @@ -315,7 +315,7 @@ export class SavedViewsComponent view['set_permissions'] = permissions.set_permissions this.savedViewService.patch(view as SavedView).subscribe({ next: () => { - this.toastService.showInfo($localize`Permissions updated`) + this.toastService.showSuccess($localize`Permissions updated`) modal.close() this.reloadViews() }, diff --git a/src-ui/src/app/components/manage/share-links/share-link-bundle-list/share-link-bundle-list.component.spec.ts b/src-ui/src/app/components/manage/share-links/share-link-bundle-list/share-link-bundle-list.component.spec.ts index 085ece5c8..bde7eeb06 100644 --- a/src-ui/src/app/components/manage/share-links/share-link-bundle-list/share-link-bundle-list.component.spec.ts +++ b/src-ui/src/app/components/manage/share-links/share-link-bundle-list/share-link-bundle-list.component.spec.ts @@ -22,6 +22,7 @@ class MockShareLinkBundleService { class MockToastService { showInfo = jest.fn() + showSuccess = jest.fn() showError = jest.fn() } @@ -210,7 +211,7 @@ describe('ShareLinkBundleListComponent', () => { component.delete(sampleBundle()) expect(service.delete).toHaveBeenCalled() - expect(toastService.showInfo).toHaveBeenCalledWith( + expect(toastService.showSuccess).toHaveBeenCalledWith( expect.stringContaining('deleted.') ) expect(service.list).toHaveBeenCalledTimes(2) diff --git a/src-ui/src/app/components/manage/share-links/share-link-bundle-list/share-link-bundle-list.component.ts b/src-ui/src/app/components/manage/share-links/share-link-bundle-list/share-link-bundle-list.component.ts index 00d04a77e..16f4be567 100644 --- a/src-ui/src/app/components/manage/share-links/share-link-bundle-list/share-link-bundle-list.component.ts +++ b/src-ui/src/app/components/manage/share-links/share-link-bundle-list/share-link-bundle-list.component.ts @@ -178,7 +178,7 @@ export class ShareLinkBundleListComponent if (this.bundles().length === 1 && this.page() > 1) { this.page.update((page) => page - 1) } - this.toastService.showInfo($localize`Share link bundle deleted.`) + this.toastService.showSuccess($localize`Share link bundle deleted.`) this.triggerRefresh(false) }, error: (e) => { diff --git a/src-ui/src/app/components/manage/share-links/share-link-list/share-link-list.component.spec.ts b/src-ui/src/app/components/manage/share-links/share-link-list/share-link-list.component.spec.ts index 1946b7e78..655fdcb75 100644 --- a/src-ui/src/app/components/manage/share-links/share-link-list/share-link-list.component.spec.ts +++ b/src-ui/src/app/components/manage/share-links/share-link-list/share-link-list.component.spec.ts @@ -15,7 +15,7 @@ describe('ShareLinkListComponent', () => { let fixture: ComponentFixture let service: jest.Mocked> let clipboard: Clipboard - let toastService: jest.Mocked> + let toastService: jest.Mocked> const link = { id: 1, @@ -33,7 +33,7 @@ describe('ShareLinkListComponent', () => { delete: jest.fn().mockReturnValue(of(true)), } toastService = { - showInfo: jest.fn(), + showSuccess: jest.fn(), showError: jest.fn(), } @@ -130,7 +130,7 @@ describe('ShareLinkListComponent', () => { expect(component.copiedID()).toBe(link.id) expect(fixture.nativeElement.querySelector('.badge.show')).not.toBeNull() - expect(toastService.showInfo).not.toHaveBeenCalled() + expect(toastService.showSuccess).not.toHaveBeenCalled() jest.advanceTimersByTime(3000) expect(component.copiedID()).toBeNull() @@ -142,7 +142,7 @@ describe('ShareLinkListComponent', () => { expect(service.delete).toHaveBeenCalledWith(link) expect(service.list).toHaveBeenCalledTimes(2) - expect(toastService.showInfo).toHaveBeenCalled() + expect(toastService.showSuccess).toHaveBeenCalled() }) it('shows an error when loading fails', () => { diff --git a/src-ui/src/app/components/manage/share-links/share-link-list/share-link-list.component.ts b/src-ui/src/app/components/manage/share-links/share-link-list/share-link-list.component.ts index 6f30366f4..b13d21701 100644 --- a/src-ui/src/app/components/manage/share-links/share-link-list/share-link-list.component.ts +++ b/src-ui/src/app/components/manage/share-links/share-link-list/share-link-list.component.ts @@ -151,7 +151,7 @@ export class ShareLinkListComponent if (this.links().length === 1 && this.page() > 1) { this.page.update((page) => page - 1) } - this.toastService.showInfo($localize`Share link deleted.`) + this.toastService.showSuccess($localize`Share link deleted.`) this.reload() }, error: (error) => { diff --git a/src-ui/src/app/components/manage/workflows/workflows.component.spec.ts b/src-ui/src/app/components/manage/workflows/workflows.component.spec.ts index c9e5e6ff8..7a64b0b72 100644 --- a/src-ui/src/app/components/manage/workflows/workflows.component.spec.ts +++ b/src-ui/src/app/components/manage/workflows/workflows.component.spec.ts @@ -128,7 +128,7 @@ describe('WorkflowsComponent', () => { 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 toastInfoSpy = jest.spyOn(toastService, 'showSuccess') const reloadSpy = jest.spyOn(component, 'reload') const createButton = fixture.debugElement.queryAll(By.css('button'))[1] @@ -152,7 +152,7 @@ describe('WorkflowsComponent', () => { 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 toastInfoSpy = jest.spyOn(toastService, 'showSuccess') const reloadSpy = jest.spyOn(component, 'reload') const editButton = fixture.debugElement.queryAll(By.css('button'))[2] @@ -268,7 +268,7 @@ describe('WorkflowsComponent', () => { By.css('input[type="checkbox"]') ) const toastErrorSpy = jest.spyOn(toastService, 'showError') - const toastInfoSpy = jest.spyOn(toastService, 'showInfo') + const toastInfoSpy = jest.spyOn(toastService, 'showSuccess') // fail first patchSpy.mockReturnValueOnce( throwError(() => new Error('Error getting config')) diff --git a/src-ui/src/app/components/manage/workflows/workflows.component.ts b/src-ui/src/app/components/manage/workflows/workflows.component.ts index 7ec0b607c..810519606 100644 --- a/src-ui/src/app/components/manage/workflows/workflows.component.ts +++ b/src-ui/src/app/components/manage/workflows/workflows.component.ts @@ -86,7 +86,7 @@ export class WorkflowsComponent modal.componentInstance.succeeded .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe((newWorkflow) => { - this.toastService.showInfo( + this.toastService.showSuccess( $localize`Saved workflow "${newWorkflow.name}".` ) this.workflowService.clearCache() @@ -138,7 +138,7 @@ export class WorkflowsComponent this.workflowService.delete(workflow).subscribe({ next: () => { modal.close() - this.toastService.showInfo( + this.toastService.showSuccess( $localize`Deleted workflow "${workflow.name}".` ) this.workflowService.clearCache() @@ -157,7 +157,7 @@ export class WorkflowsComponent toggleWorkflowEnabled(workflow: Workflow) { this.workflowService.patch(workflow).subscribe({ next: () => { - this.toastService.showInfo( + this.toastService.showSuccess( workflow.enabled ? $localize`Enabled workflow "${workflow.name}"` : $localize`Disabled workflow "${workflow.name}"` diff --git a/src-ui/src/app/data/ui-settings.ts b/src-ui/src/app/data/ui-settings.ts index 32c5242bd..48cf67658 100644 --- a/src-ui/src/app/data/ui-settings.ts +++ b/src-ui/src/app/data/ui-settings.ts @@ -62,14 +62,10 @@ export const SETTINGS_KEYS = { 'general-settings:document-details:pdf-viewer-zoom-setting', DATE_LOCALE: 'general-settings:date-display:date-locale', DATE_FORMAT: 'general-settings:date-display:date-format', - NOTIFICATIONS_CONSUMER_NEW_DOCUMENT: - 'general-settings:notifications:consumer-new-documents', NOTIFICATIONS_CONSUMER_SUCCESS: 'general-settings:notifications:consumer-success', NOTIFICATIONS_CONSUMER_FAILED: 'general-settings:notifications:consumer-failed', - NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD: - 'general-settings:notifications:consumer-suppress-on-dashboard', NOTES_ENABLED: 'general-settings:notes-enabled', AUDITLOG_ENABLED: 'general-settings:auditlog-enabled', SLIM_SIDEBAR: 'general-settings:slim-sidebar', @@ -197,11 +193,6 @@ export const SETTINGS: UiSetting[] = [ type: 'string', default: 'mediumDate', }, - { - key: SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT, - type: 'boolean', - default: true, - }, { key: SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS, type: 'boolean', @@ -212,11 +203,6 @@ export const SETTINGS: UiSetting[] = [ type: 'boolean', default: true, }, - { - key: SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD, - type: 'boolean', - default: true, - }, { key: SETTINGS_KEYS.NOTES_ENABLED, type: 'boolean', diff --git a/src-ui/src/app/services/settings.service.spec.ts b/src-ui/src/app/services/settings.service.spec.ts index 010f2f1c7..804d78b17 100644 --- a/src-ui/src/app/services/settings.service.spec.ts +++ b/src-ui/src/app/services/settings.service.spec.ts @@ -167,7 +167,7 @@ describe('SettingsService', () => { expect(settingsService.getLanguage()).toEqual('') expect(settingsService.get(SETTINGS_KEYS.DARK_MODE_ENABLED)).toBeFalsy() expect( - settingsService.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT) + settingsService.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS) ).toBeTruthy() expect(settingsService.get(SETTINGS_KEYS.DOCUMENT_LIST_SIZE)).toEqual(50) expect(settingsService.get(SETTINGS_KEYS.THEME_COLOR)).toEqual('#9fbf2f') @@ -198,17 +198,14 @@ describe('SettingsService', () => { settingsService.setLanguage('de-de') settingsService.set(SETTINGS_KEYS.DARK_MODE_ENABLED, true) - settingsService.set( - SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT, - false - ) + settingsService.set(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS, false) settingsService.set(SETTINGS_KEYS.DOCUMENT_LIST_SIZE, 25) settingsService.set(SETTINGS_KEYS.THEME_COLOR, '#000000') expect(settingsService.getLanguage()).toEqual('de-de') expect(settingsService.get(SETTINGS_KEYS.DARK_MODE_ENABLED)).toBeTruthy() expect( - settingsService.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT) + settingsService.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS) ).toBeFalsy() expect(settingsService.get(SETTINGS_KEYS.DOCUMENT_LIST_SIZE)).toEqual(25) expect(settingsService.get(SETTINGS_KEYS.THEME_COLOR)).toEqual('#000000') diff --git a/src-ui/src/app/services/settings.service.ts b/src-ui/src/app/services/settings.service.ts index ef27e6e55..005c70667 100644 --- a/src-ui/src/app/services/settings.service.ts +++ b/src-ui/src/app/services/settings.service.ts @@ -679,7 +679,7 @@ export class SettingsService { .subscribe({ next: () => { this.updateAppearanceSettings() - this.toastService.showInfo(successMessage) + this.toastService.showSuccess(successMessage) }, error: (e) => { this.toastService.showError(errorMessage) @@ -741,9 +741,10 @@ export class SettingsService { this.storeSettings() .pipe(first()) .subscribe(() => { - this.toastService.showInfo( - $localize`You can restart the tour from the settings page.` - ) + this.toastService.show({ + content: $localize`You can restart the tour from the settings page.`, + delay: 5000, + }) }) } } diff --git a/src-ui/src/app/services/toast.service.spec.ts b/src-ui/src/app/services/toast.service.spec.ts index ce50b165e..9ddb85620 100644 --- a/src-ui/src/app/services/toast.service.spec.ts +++ b/src-ui/src/app/services/toast.service.spec.ts @@ -1,5 +1,5 @@ import { TestBed } from '@angular/core/testing' -import { ToastService } from './toast.service' +import { Toast, ToastLevel, ToastService } from './toast.service' describe('ToastService', () => { let toastService: ToastService @@ -68,6 +68,53 @@ describe('ToastService', () => { }) }) + it('keeps showSuccess toasts out of the toast list', () => { + let toasts: Toast[] = [] + const popups: Toast[] = [] + toastService.getToasts().subscribe((t) => (toasts = [...t])) + toastService.showToast.subscribe((t) => popups.push(t)) + + toastService.showSuccess('Saved') + + expect(popups.map((t) => t.content)).toEqual(['Saved']) + expect(toasts).toHaveLength(0) + }) + + it('keeps showInfo toasts out of the toast list', () => { + let toasts: Toast[] = [] + const popups: Toast[] = [] + toastService.getToasts().subscribe((t) => (toasts = [...t])) + toastService.showToast.subscribe((t) => popups.push(t)) + + toastService.showInfo('Info toast') + toastService.showError('Error toast') + + expect(popups.map((t) => t.content)).toEqual(['Info toast', 'Error toast']) + expect(toasts.map((t) => t.content)).toEqual(['Error toast']) + }) + + it('adds toasts to the list unless persistent is false', () => { + let toasts: Toast[] = [] + toastService.getToasts().subscribe((t) => (toasts = [...t])) + + toastService.show({ content: 'kept', delay: 5000 }) + toastService.show({ content: 'kept too', delay: 5000, persistent: true }) + toastService.show({ content: 'popup only', delay: 5000, persistent: false }) + + expect(toasts.map((t) => t.content)).toEqual(['kept too', 'kept']) + }) + + it('still pops up non-persistent toasts while popups are suppressed', () => { + const popups: Toast[] = [] + toastService.suppressPopupToasts = true + toastService.showToast.subscribe((t) => popups.push(t)) + + toastService.show({ content: 'kept', delay: 5000 }) + toastService.showInfo('popup only') + + expect(popups.map((t) => t?.content)).toEqual(['popup only']) + }) + it('removes toast on close', () => { const toast = { title: 'Title', @@ -106,4 +153,46 @@ describe('ToastService', () => { toastService.suppressPopupToasts = true toastService.showInfo('Info toast') }) + + it('sets the level for showSuccess, showInfo and showError', () => { + const shown: Toast[] = [] + toastService.showToast.subscribe((t) => shown.push(t)) + toastService.showSuccess('Saved') + toastService.showInfo('Started') + toastService.showError('Failed') + expect(shown.map((t) => t.level)).toEqual([ + ToastLevel.Success, + undefined, + ToastLevel.Error, + ]) + }) + + it('adds silent toasts to the list without a popup', () => { + let toasts: Toast[] = [] + const popups: Toast[] = [] + toastService.getToasts().subscribe((t) => (toasts = [...t])) + toastService.showToast.subscribe((t) => popups.push(t)) + + toastService.show({ content: 'Recorded', delay: 5000, silent: true }) + + expect(toasts.map((t) => t.content)).toEqual(['Recorded']) + expect(popups).toHaveLength(0) + }) + + it('timestamps toasts and tracks read state', () => { + let toasts: Toast[] = [] + toastService.getToasts().subscribe((t) => (toasts = [...t])) + + toastService.showError('Failed') + expect(toasts[0].timestamp).toBeInstanceOf(Date) + expect(toasts[0].read).toBe(false) + + toastService.markAllRead() + expect(toasts[0].read).toBe(true) + + // arriving while the dropdown is open counts as seen + toastService.suppressPopupToasts = true + toastService.showError('Failed again') + expect(toasts[0].read).toBe(true) + }) }) diff --git a/src-ui/src/app/services/toast.service.ts b/src-ui/src/app/services/toast.service.ts index b917bf94b..6d9916143 100644 --- a/src-ui/src/app/services/toast.service.ts +++ b/src-ui/src/app/services/toast.service.ts @@ -2,6 +2,12 @@ import { Injectable } from '@angular/core' import { Subject } from 'rxjs' import { v4 as uuidv4 } from 'uuid' +export enum ToastLevel { + Info = 'info', + Success = 'success', + Error = 'error', +} + export interface Toast { id?: string @@ -9,15 +15,32 @@ export interface Toast { delay: number - delayRemaining?: number - action?: any actionName?: string - classname?: string + /** Defaults to ToastLevel.Info */ + level?: ToastLevel error?: any + + /** + * Whether the toast is kept in the notifications dropdown after its popup + * closes. Defaults to true. + */ + persistent?: boolean + + /** + * Adds the toast to the notifications dropdown without a popup, e.g. when + * the same information is already on screen. Defaults to false. + */ + silent?: boolean + + /** When the toast was shown, set automatically */ + timestamp?: Date + + /** Whether the toast has been seen in the notifications dropdown */ + read?: boolean } @Injectable({ @@ -42,29 +65,51 @@ export class ToastService { if (!toast.id) { toast.id = uuidv4() } + toast.timestamp ??= new Date() if (typeof toast.error === 'string') { try { toast.error = JSON.parse(toast.error) } catch (e) {} } - this.toasts.unshift(toast) - if (!this._suppressPopupToasts) { + const persistent = toast.persistent !== false + if (persistent) { + // the dropdown is open, so it's seen as it arrives + toast.read = !!this._suppressPopupToasts + this.toasts.unshift(toast) + this.toastsSubject.next(this.toasts) + } + // Popups are suppressed while the dropdown is open + if (!toast.silent && (!this._suppressPopupToasts || !persistent)) { this.showToast.next(toast) } - this.toastsSubject.next(this.toasts) } showError(content: string, error: any = null, delay: number = 10000) { this.show({ content: content, delay: delay, - classname: 'error', + level: ToastLevel.Error, error, }) } showInfo(content: string, delay: number = 5000) { - this.show({ content: content, delay: delay }) + this.show({ content: content, delay: delay, persistent: false }) + } + + /** For confirming that something the user did has completed */ + showSuccess(content: string, delay: number = 5000) { + this.show({ + content: content, + delay: delay, + level: ToastLevel.Success, + persistent: false, + }) + } + + markAllRead() { + this.toasts.forEach((toast) => (toast.read = true)) + this.toastsSubject.next(this.toasts) } closeToast(toast: Toast) { diff --git a/src-ui/src/app/services/trash.service.spec.ts b/src-ui/src/app/services/trash.service.spec.ts index 19126be7d..5cdee2dea 100644 --- a/src-ui/src/app/services/trash.service.spec.ts +++ b/src-ui/src/app/services/trash.service.spec.ts @@ -5,11 +5,15 @@ import { HttpTestingController, } from '@angular/common/http/testing' import { environment } from 'src/environments/environment' +import { DocumentListViewService } from './document-list-view.service' +import { Toast, ToastService } from './toast.service' import { TrashService } from './trash.service' describe('TrashService', () => { let service: TrashService let httpTestingController: HttpTestingController + let toastService: ToastService + let documentListViewService: DocumentListViewService beforeEach(() => { TestBed.configureTestingModule({ @@ -17,6 +21,8 @@ describe('TrashService', () => { }) service = TestBed.inject(TrashService) httpTestingController = TestBed.inject(HttpTestingController) + toastService = TestBed.inject(ToastService) + documentListViewService = TestBed.inject(DocumentListViewService) }) it('should call correct endpoint for getTrash', () => { @@ -56,4 +62,47 @@ describe('TrashService', () => { documents: [1, 2, 3], }) }) + + it('should show a persistent moved to trash toast with undo', () => { + let toasts: Toast[] = [] + toastService.getToasts().subscribe((t) => (toasts = [...t])) + const reloadSpy = jest + .spyOn(documentListViewService, 'reload') + .mockImplementation(() => {}) + + service.notifyMovedToTrash('Moved 2 document(s) to the trash', [1, 2]) + expect(toasts).toHaveLength(1) + expect(toasts[0].actionName).toEqual('Undo') + + toasts[0].action() + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}trash/` + ) + expect(req.request.body).toEqual({ action: 'restore', documents: [1, 2] }) + req.flush('OK') + expect(reloadSpy).toHaveBeenCalled() + expect(toasts[0].content).toEqual('Document(s) restored') + }) + + it('should show an error if undo fails', () => { + let toast: Toast + toastService.getToasts().subscribe((t) => (toast = t[0])) + const errorSpy = jest.spyOn(toastService, 'showError') + + service.notifyMovedToTrash('Moved "Invoice" to the trash', [1]) + toast.action() + httpTestingController + .expectOne(`${environment.apiBaseUrl}trash/`) + .flush('error', { status: 500, statusText: 'Server Error' }) + expect(errorSpy).toHaveBeenCalled() + }) + + it('should not offer undo without document ids', () => { + let toast: Toast + toastService.getToasts().subscribe((t) => (toast = t[0])) + + service.notifyMovedToTrash('Moved 500 document(s) to the trash') + expect(toast.content).toEqual('Moved 500 document(s) to the trash') + expect(toast.action).toBeUndefined() + }) }) diff --git a/src-ui/src/app/services/trash.service.ts b/src-ui/src/app/services/trash.service.ts index 7d608e518..4db55c658 100644 --- a/src-ui/src/app/services/trash.service.ts +++ b/src-ui/src/app/services/trash.service.ts @@ -4,12 +4,16 @@ import { Observable } from 'rxjs' import { environment } from 'src/environments/environment' import { Document } from '../data/document' import { Results } from '../data/results' +import { DocumentListViewService } from './document-list-view.service' +import { ToastLevel, ToastService } from './toast.service' @Injectable({ providedIn: 'root', }) export class TrashService { private http = inject(HttpClient) + private toastService = inject(ToastService) + private documentListViewService = inject(DocumentListViewService) public getTrash(page: number = 1): Observable> { const httpParams = new HttpParams().set('page', page.toString()) @@ -34,4 +38,34 @@ export class TrashService { documents, }) } + + /** + * Shows a toast with an Undo action when possible + */ + public notifyMovedToTrash(content: string, documents?: number[]) { + const canUndo = documents?.length > 0 + this.toastService.show({ + content, + delay: 10000, + level: ToastLevel.Success, + actionName: canUndo ? $localize`Undo` : undefined, + action: canUndo ? () => this.undoMoveToTrash(documents) : undefined, + }) + } + + private undoMoveToTrash(documents: number[]) { + this.restoreDocuments(documents).subscribe({ + next: () => { + this.toastService.show({ + content: $localize`Document(s) restored`, + delay: 5000, + level: ToastLevel.Success, + }) + this.documentListViewService.reload() + }, + error: (e) => { + this.toastService.showError($localize`Error restoring document(s)`, e) + }, + }) + } } diff --git a/src-ui/src/main.ts b/src-ui/src/main.ts index 329ee4e23..c52f5f4c5 100644 --- a/src-ui/src/main.ts +++ b/src-ui/src/main.ts @@ -69,7 +69,9 @@ import { checkLg, chevronDoubleLeft, chevronDoubleRight, + chevronDown, chevronRight, + chevronUp, circle, clipboard, clipboardCheck, @@ -322,7 +324,9 @@ const icons = { checkLg, chevronDoubleLeft, chevronDoubleRight, + chevronDown, chevronRight, + chevronUp, circle, clipboard, clipboardCheck, diff --git a/src-ui/src/theme.scss b/src-ui/src/theme.scss index f3d75369b..c4854864e 100644 --- a/src-ui/src/theme.scss +++ b/src-ui/src/theme.scss @@ -26,6 +26,14 @@ --pngx-card-hover-border: var(--bs-tertiary-color); --pngx-focus-alpha: 0.3; --pngx-toast-max-width: 340px; + --pngx-toast-bg: rgba(255, 255, 255, 0.94); + --pngx-toast-success: hsl(var(--pngx-primary), 30%); + --pngx-toast-success-bg: hsl(var(--pngx-primary), 94%); + --pngx-toast-info: hsl(211, 86%, 47%); + --pngx-toast-error: hsl(352, 78%, 45%); + --pngx-toast-error-bg: hsl(352, 80%, 96%); + --pngx-toast-row-bg: #f3f5f4; + --pngx-toast-row-hover-bg: #eaeeec; --bs-info: var(--pngx-bg-alt2); --bs-info-rgb: 233, 236, 239; @media screen and (min-width: 1024px) { @@ -55,10 +63,6 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,%3csvg xmlns='h --bs-form-check-bg-image: #{$form-check-radio-checked-bg-image-dark}; } } - - .toast .btn-close { - filter: none !important; - } } .primary-dark { @@ -90,6 +94,14 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,%3csvg xmlns='h color-scheme: dark; --pngx-body-color-accent: #{$text-color-dark-bg-accent}; --pngx-bg-alt: #242529; + --pngx-toast-bg: rgba(36, 40, 43, 0.96); + --pngx-toast-success: hsl(var(--pngx-primary), 60%); + --pngx-toast-success-bg: hsl(var(--pngx-primary), 14%); + --pngx-toast-info: hsl(211, 95%, 68%); + --pngx-toast-error: hsl(352, 90%, 68%); + --pngx-toast-error-bg: hsl(352, 45%, 20%); + --pngx-toast-row-bg: #26282c; + --pngx-toast-row-hover-bg: #2d2f34; --pngx-bg-alt2: #232323; --pngx-bg-darker: #121315; --pngx-bg-disabled: var(--pngx-bg-alt); @@ -302,16 +314,6 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,%3csvg xmlns='h filter: invert(1) grayscale(100%) brightness(200%); } - .toast { - background-color: hsla(var(--pngx-primary), calc(var(--pngx-primary-lightness) - 15%), 0.9); - } - - .toast, - .toast .btn, - .toast .btn-close { - color: var(--pngx-primary-text-contrast); - } - .dropdown-item { --bs-dropdown-color: var(--bs-body-color); --pngx-bg-darker: var(--pngx-bg-alt);