mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-20 06:48:16 +00:00
Enhancement: notifications improvements (#14500)
This commit is contained in:
1 parent
c657fceea5
commit
98fb7c3b63
77 files changed
+1535
-627
No files matched your search
@@ -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<FileStatus>()
|
||||
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<FileStatus>()
|
||||
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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -425,10 +425,8 @@
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<pngx-input-check i18n-title title="Show notifications when new documents are detected" formControlName="notificationsConsumerNewDocument"></pngx-input-check>
|
||||
<pngx-input-check i18n-title title="Show notifications when document processing completes successfully" formControlName="notificationsConsumerSuccess"></pngx-input-check>
|
||||
<pngx-input-check i18n-title title="Add notifications when document processing completes successfully" formControlName="notificationsConsumerSuccess" i18n-hint hint="These are added to the notifications menu without a popup, processing progress is shown at the bottom of the screen."></pngx-input-check>
|
||||
<pngx-input-check i18n-title title="Show notifications when document processing fails" formControlName="notificationsConsumerFailed"></pngx-input-check>
|
||||
<pngx-input-check i18n-title title="Suppress notifications on dashboard" formControlName="notificationsConsumerSuppressOnDashboard" i18n-hint hint="This will suppress all messages about document processing status on the dashboard."></pngx-input-check>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -427,5 +427,6 @@
|
||||
[ngClass]="slimSidebarEnabled ? 'col-slim' : 'col-sidebar-expanded'">
|
||||
<router-outlet></router-outlet>
|
||||
</main>
|
||||
<pngx-upload-status-tray></pngx-upload-status-tray>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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)
|
||||
|
||||
+20
-17
@@ -1,28 +1,31 @@
|
||||
|
||||
<li ngbDropdown class="nav-item position-relative" (openChange)="onOpenChange($event)">
|
||||
@if (toasts().length) {
|
||||
<span class="notification-count badge rounded-pill z-3 pe-none bg-secondary position-absolute">{{ toasts().length }}</span>
|
||||
@if (unread().length) {
|
||||
<span class="notification-count badge rounded-pill z-3 pe-none position-absolute" [class.bg-danger]="hasUnreadError()" [class.bg-secondary]="!hasUnreadError()">{{ unread().length }}</span>
|
||||
}
|
||||
<button class="btn navbar-action border-0" id="notificationsDropdown" ngbDropdownToggle aria-label="Notifications" i18n-aria-label>
|
||||
<i-bs width="1.3em" height="1.3em" name="bell"></i-bs>
|
||||
</button>
|
||||
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="notificationsDropdown">
|
||||
<div class="btn-toolbar align-items-center" role="toolbar">
|
||||
<h6 i18n>Notifications</h6>
|
||||
<div class="btn-group ms-auto">
|
||||
<button class="btn btn-sm btn-outline-secondary mb-2 ms-auto"
|
||||
(click)="toastService.clearToasts()"
|
||||
[disabled]="toasts().length === 0"
|
||||
i18n>Clear All</button>
|
||||
</div>
|
||||
<div ngbDropdownMenu class="dropdown-menu-end shadow p-2" aria-labelledby="notificationsDropdown">
|
||||
<div class="d-flex align-items-center px-1 mb-2">
|
||||
<h6 class="mb-0" i18n>Notifications</h6>
|
||||
@if (toasts().length) {
|
||||
<button class="btn btn-link btn-sm p-0 ms-auto text-body-secondary text-decoration-none clear-all"
|
||||
(click)="toastService.clearToasts()">
|
||||
<i-bs width="1em" height="1em" name="check2-all" class="me-1"></i-bs><ng-container i18n>Clear All</ng-container>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (toasts().length === 0) {
|
||||
<p class="text-center mb-0 small text-muted"><em i18n>No notifications</em></p>
|
||||
}
|
||||
<div class="scroll-list">
|
||||
@for (toast of toasts(); track toast.id) {
|
||||
<pngx-toast [autohide]="false" [toast]="toast" (hidden)="onHidden(toast)" (closed)="toastService.closeToast(toast)"></pngx-toast>
|
||||
}
|
||||
<div class="text-center text-body-secondary small py-4">
|
||||
<i-bs width="1.5em" height="1.5em" name="bell" class="d-block mx-auto mb-2 opacity-50"></i-bs>
|
||||
<ng-container i18n>No notifications</ng-container>
|
||||
</div>
|
||||
}
|
||||
<div class="scroll-list d-flex flex-column gap-2">
|
||||
@for (toast of toasts(); track toast.id) {
|
||||
<pngx-toast [autohide]="false" [toast]="toast" [now]="now()" [isNew]="newIds().has(toast.id)" (closed)="toastService.closeToast(toast)" (actioned)="onActioned(toast)"></pngx-toast>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
@@ -30,3 +30,7 @@
|
||||
right: -3rem;
|
||||
}
|
||||
}
|
||||
|
||||
.clear-all:hover {
|
||||
color: var(--bs-body-color) !important;
|
||||
}
|
||||
+70
-1
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<string>())
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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'
|
||||
|
||||
+3
-1
@@ -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)
|
||||
|
||||
+1
-1
@@ -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()
|
||||
|
||||
+1
-1
@@ -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`)
|
||||
|
||||
+2
-2
@@ -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'
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+3
-3
@@ -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()
|
||||
|
||||
+7
-3
@@ -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`)
|
||||
}
|
||||
|
||||
@@ -1,22 +1,33 @@
|
||||
<ngb-toast
|
||||
[autohide]="autohide"
|
||||
[delay]="toast.delay"
|
||||
[class]="toast.classname"
|
||||
[class.mb-2]="true"
|
||||
[autohide]="false"
|
||||
[class.mb-2]="autohide"
|
||||
[class.toast-list-item]="!autohide"
|
||||
[class.toast-new]="isNew"
|
||||
[class.toast-success]="toast.level === ToastLevel.Success"
|
||||
[class.toast-error]="toast.level === ToastLevel.Error"
|
||||
(mouseenter)="setHovered(true)"
|
||||
(mouseleave)="setHovered(false)"
|
||||
(focusin)="setFocused(true)"
|
||||
(focusout)="setFocused(false)"
|
||||
(shown)="onShown(toast)"
|
||||
(hidden)="hidden.emit(toast)">
|
||||
@if (autohide) {
|
||||
<ngb-progressbar class="position-absolute h-100 w-100 top-90 start-0 bottom-0 end-0 pe-none" type="dark" [max]="toast.delay" [value]="toast.delayRemaining"></ngb-progressbar>
|
||||
<span class="visually-hidden">{{ toast.delayRemaining / 1000 | number: '1.0-0' }} seconds</span>
|
||||
<ngb-progressbar class="position-absolute w-100 start-0 bottom-0 pe-none" [max]="toast.delay" [value]="delayRemaining()"></ngb-progressbar>
|
||||
<span class="visually-hidden">{{ delayRemaining() / 1000 | number: '1.0-0' }} seconds</span>
|
||||
}
|
||||
<div class="d-flex align-items-top">
|
||||
@if (!toast.error) {
|
||||
<i-bs width="0.9em" height="0.9em" name="info-circle"></i-bs>
|
||||
@switch (toast.level) {
|
||||
@case (ToastLevel.Success) {
|
||||
<i-bs width="0.9em" height="0.9em" name="check-circle" class="toast-icon"></i-bs>
|
||||
}
|
||||
@case (ToastLevel.Error) {
|
||||
<i-bs width="0.9em" height="0.9em" name="exclamation-triangle" class="toast-icon"></i-bs>
|
||||
}
|
||||
@default {
|
||||
<i-bs width="0.9em" height="0.9em" name="info-circle" class="toast-icon"></i-bs>
|
||||
}
|
||||
}
|
||||
@if (toast.error) {
|
||||
<i-bs width="0.9em" height="0.9em" name="exclamation-triangle"></i-bs>
|
||||
}
|
||||
<div>
|
||||
<div class="toast-content">
|
||||
<p class="ms-2 mb-0 text-break">{{toast.content}}</p>
|
||||
@if (toast.error) {
|
||||
<details class="ms-2">
|
||||
@@ -48,9 +59,12 @@
|
||||
</details>
|
||||
}
|
||||
@if (toast.action) {
|
||||
<p class="mb-0 mt-2"><button class="btn btn-sm btn-outline-secondary" (click)="closed.emit(toast); toast.action()">{{toast.actionName}}</button></p>
|
||||
<button class="btn btn-sm toast-action ms-2 mt-2" (click)="actioned.emit(toast); toast.action()">{{toast.actionName}}</button>
|
||||
}
|
||||
</div>
|
||||
@if (!autohide && toast.timestamp) {
|
||||
<span class="toast-time small text-body-secondary text-nowrap ms-auto ps-2">{{relativeTimestamp}}</span>
|
||||
}
|
||||
<button type="button" class="btn-close ms-auto flex-shrink-0" data-bs-dismiss="toast" aria-label="Close" (click)="closed.emit(toast);"></button>
|
||||
</div>
|
||||
</ngb-toast>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<number>(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<Toast> = new EventEmitter<Toast>()
|
||||
|
||||
@Output() closed: EventEmitter<Toast> = new EventEmitter<Toast>()
|
||||
|
||||
/** The toast's action button was clicked */
|
||||
@Output() actioned: EventEmitter<Toast> = new EventEmitter<Toast>()
|
||||
|
||||
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()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
@for (toast of toasts(); track toast.id) {
|
||||
<pngx-toast [toast]="toast" [autohide]="true" (closed)="closeToast()"></pngx-toast>
|
||||
<pngx-toast [toast]="toast" [autohide]="true" (closed)="removePopup(toast)" (actioned)="closeToast(toast)" (hidden)="removePopup(toast)"></pngx-toast>
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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<void>()
|
||||
readonly toasts = signal<Toast[]>([])
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
@if (canAddDocuments && getStatus().length > 0) {
|
||||
<div class="fixed-bottom p-2 p-md-4 d-flex justify-content-end pe-none consumer-status-list" [ngClass]="slimSidebarEnabled ? 'col-slim' : 'offset-sidebar-expanded'">
|
||||
<div class="col col-lg-4 col-xl-3 ps-0 pe-0 ps-lg-3 pe-lg-0 pe-auto overflow-y-auto">
|
||||
<div class="card shadow-sm consumer-status-card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-wrap align-items-center column-gap-2">
|
||||
<p class="m-0 small text-muted">{{getStatusSummary()}}</p>
|
||||
<div class="d-flex align-items-center gap-2 ms-auto">
|
||||
@if (getStatusCompleted().length > 0) {
|
||||
<a class="btn-link small text-nowrap" (click)="dismissCompleted()" [routerLink]="[]">
|
||||
<span class="me-1" i18n="This button dismisses all status messages about processed documents in the tray (failed and successful)">Dismiss completed</span>
|
||||
<i-bs name="check2-all"></i-bs>
|
||||
</a>
|
||||
}
|
||||
<button type="button" class="btn btn-sm btn-link p-0 text-muted" (click)="toggleCollapsed()"
|
||||
[attr.aria-expanded]="!collapsed()" aria-label="Toggle document processing status" i18n-aria-label>
|
||||
<i-bs [name]="collapsed() ? 'chevron-up' : 'chevron-down'"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (!collapsed()) {
|
||||
@for (status of getStatus(); track status) {
|
||||
<ng-container [ngTemplateOutlet]="consumerAlert" [ngTemplateOutletContext]="{ $implicit: status }"></ng-container>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<ng-template #consumerAlert let-status>
|
||||
<ngb-alert type="secondary" class="mt-2 mb-0" [dismissible]="isFinished(status)" (closed)="dismiss(status)">
|
||||
<h6 class="alert-heading">{{status.filename}}</h6>
|
||||
@if (!isFinished(status) || (isFinished(status) && !status.documentId)) {
|
||||
<p class="mb-0 pb-1">{{status.message}}</p>
|
||||
}
|
||||
<ngb-progressbar [value]="status.getProgress()" [max]="1" [type]="getStatusColor(status)"></ngb-progressbar>
|
||||
<div *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||
@if (isFinished(status)) {
|
||||
<div>
|
||||
@if (status.documentId) {
|
||||
<a class="btn btn-sm btn-outline-primary btn-open" [routerLink]="['/documents', status.documentId]" (click)="dismiss(status)">
|
||||
<small i18n>Open document</small>
|
||||
<i-bs name="arrow-right-short"></i-bs>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</ngb-alert>
|
||||
</ng-template>
|
||||
@@ -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;
|
||||
}
|
||||
+186
@@ -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<UploadStatusTrayComponent>
|
||||
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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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[]
|
||||
|
||||
@@ -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)
|
||||
|
||||
-54
@@ -8,59 +8,5 @@
|
||||
</button>
|
||||
<input type="file" class="visually-hidden" (change)="onFileSelected($event)" multiple #fileUpload>
|
||||
</form>
|
||||
@if (getStatus().length > 0) {
|
||||
<div class="fixed-bottom p-2 p-md-4 d-flex justify-content-end pe-none consumer-status-list" [ngClass]="slimSidebarEnabled ? 'col-slim' : 'offset-sidebar-expanded'">
|
||||
<div class="col col-lg-4 col-xl-3 ps-0 pe-0 ps-lg-3 pe-lg-0 pe-auto overflow-y-scroll">
|
||||
<div class="card shadow-sm consumer-status-card">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col d-flex justify-content-between align-items-center">
|
||||
@if (getStatus().length > 0) {
|
||||
<p class="m-0 small text-muted">{{getStatusSummary()}}</p>
|
||||
}
|
||||
@if (getStatusCompleted().length > 0) {
|
||||
<a class="btn-link" (click)="dismissCompleted()" [routerLink]="[]" >
|
||||
<span class="me-1" i18n="This button dismisses all status messages about processed documents on the dashboard (failed and successful)">Dismiss completed</span>
|
||||
<i-bs name="check2-all"></i-bs>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
@for (status of getStatus(); track status) {
|
||||
<div>
|
||||
<ng-container [ngTemplateOutlet]="consumerAlert" [ngTemplateOutletContext]="{ $implicit: status }"></ng-container>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</pngx-widget-frame>
|
||||
|
||||
<ng-template #consumerAlert let-status>
|
||||
<ngb-alert type="secondary" class="mt-2 mb-0" [dismissible]="isFinished(status)" (closed)="dismiss(status)">
|
||||
<h6 class="alert-heading">{{status.filename}}</h6>
|
||||
@if (!isFinished(status) || (isFinished(status) && !status.documentId)) {
|
||||
<p class="mb-0 pb-1">{{status.message}}</p>
|
||||
}
|
||||
<ngb-progressbar [value]="status.getProgress()" [max]="1" [type]="getStatusColor(status)"></ngb-progressbar>
|
||||
<div *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||
@if (isFinished(status)) {
|
||||
<div>
|
||||
@if (status.documentId) {
|
||||
<a class="btn btn-sm btn-outline-primary btn-open" [routerLink]="['/documents', status.documentId]" (click)="dismiss(status)">
|
||||
<small i18n>Open document</small>
|
||||
<i-bs name="arrow-right-short"></i-bs>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</ngb-alert>
|
||||
</ng-template>
|
||||
-42
@@ -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
|
||||
}
|
||||
}
|
||||
-104
@@ -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<UploadFileWidgetComponent>
|
||||
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
|
||||
})
|
||||
}
|
||||
+1
-114
@@ -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<NgbAlert>
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
|
||||
+5
-2
@@ -33,7 +33,9 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
| 'updateVersionLabel'
|
||||
>
|
||||
>
|
||||
let toastService: jest.Mocked<Pick<ToastService, 'showError' | 'showInfo'>>
|
||||
let toastService: jest.Mocked<
|
||||
Pick<ToastService, 'showError' | 'showInfo' | 'showSuccess'>
|
||||
>
|
||||
let finished$: Subject<{ taskId: string }>
|
||||
let failed$: Subject<{ taskId: string; message?: string }>
|
||||
let modalService: jest.Mocked<Pick<NgbModal, 'open'>>
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
+1
-1
@@ -320,7 +320,7 @@ export class DocumentVersionDropdownComponent
|
||||
}
|
||||
this.newVersionLabel = ''
|
||||
modal.close()
|
||||
this.toastService.showInfo(
|
||||
this.toastService.showSuccess(
|
||||
$localize`Existing document added as a version.`
|
||||
)
|
||||
},
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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.`
|
||||
)
|
||||
},
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+6
-2
@@ -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()
|
||||
|
||||
+4
-4
@@ -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()
|
||||
|
||||
+6
-4
@@ -224,7 +224,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
||||
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<T extends MatchingModel>
|
||||
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<T extends MatchingModel>
|
||||
.subscribe({
|
||||
next: () => {
|
||||
modal.close()
|
||||
this.toastService.showInfo(
|
||||
this.toastService.showSuccess(
|
||||
$localize`Permissions updated successfully`
|
||||
)
|
||||
this.reloadData()
|
||||
@@ -468,7 +468,9 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
||||
.subscribe({
|
||||
next: () => {
|
||||
modal.close()
|
||||
this.toastService.showInfo($localize`Objects deleted successfully`)
|
||||
this.toastService.showSuccess(
|
||||
$localize`Objects deleted successfully`
|
||||
)
|
||||
this.reloadData()
|
||||
},
|
||||
error: (error) => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
+1
-1
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
|
||||
+2
-1
@@ -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)
|
||||
|
||||
+1
-1
@@ -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) => {
|
||||
|
||||
+4
-4
@@ -15,7 +15,7 @@ describe('ShareLinkListComponent', () => {
|
||||
let fixture: ComponentFixture<ShareLinkListComponent>
|
||||
let service: jest.Mocked<Pick<ShareLinkService, 'list' | 'delete'>>
|
||||
let clipboard: Clipboard
|
||||
let toastService: jest.Mocked<Pick<ToastService, 'showInfo' | 'showError'>>
|
||||
let toastService: jest.Mocked<Pick<ToastService, 'showSuccess' | 'showError'>>
|
||||
|
||||
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', () => {
|
||||
|
||||
+1
-1
@@ -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) => {
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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}"`
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<Results<Document>> {
|
||||
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)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+16
-14
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user