mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-20 06:48:16 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b83e734aef |
No files matched your search
@@ -28,7 +28,7 @@ module.exports = {
|
||||
'abstract-paperless-service',
|
||||
],
|
||||
transformIgnorePatterns: [
|
||||
'node_modules/(?!.*(\\.mjs$|tslib|lodash-es|normalize-diacritics|marked|@angular/common/locales/.*\\.js$))',
|
||||
'node_modules/(?!.*(\\.mjs$|tslib|lodash-es|normalize-diacritics|@angular/common/locales/.*\\.js$))',
|
||||
],
|
||||
moduleNameMapper: {
|
||||
...esmPreset.moduleNameMapper,
|
||||
|
||||
+561
-665
File diff suppressed because it is too large.
Load diff
@@ -30,7 +30,6 @@
|
||||
"bootstrap": "^5.3.8",
|
||||
"file-saver": "^2.0.5",
|
||||
"lodash-es": "^4.18.1",
|
||||
"marked": "~18.0.14",
|
||||
"mime-names": "^1.0.0",
|
||||
"ngx-bootstrap-icons": "^1.9.3",
|
||||
"ngx-color": "^10.1.0",
|
||||
|
||||
Generated
-10
@@ -53,9 +53,6 @@ importers:
|
||||
lodash-es:
|
||||
specifier: ^4.18.1
|
||||
version: 4.18.1
|
||||
marked:
|
||||
specifier: ~18.0.14
|
||||
version: 18.0.14
|
||||
mime-names:
|
||||
specifier: ^1.0.0
|
||||
version: 1.0.0
|
||||
@@ -3229,11 +3226,6 @@ packages:
|
||||
make-error@1.3.6:
|
||||
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
|
||||
|
||||
marked@18.0.14:
|
||||
resolution: {integrity: sha512-mBHK6FBHuBAlhgRe88w9F0O1AbwwXJUcQibUbC/QcdTbVGAD7aWza+xt3N6oT/jCZx3/OMeS+8rnuiHZcQ9s7A==}
|
||||
engines: {node: '>= 20'}
|
||||
hasBin: true
|
||||
|
||||
material-colors@1.2.6:
|
||||
resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==}
|
||||
|
||||
@@ -7417,8 +7409,6 @@ snapshots:
|
||||
|
||||
make-error@1.3.6: {}
|
||||
|
||||
marked@18.0.14: {}
|
||||
|
||||
material-colors@1.2.6: {}
|
||||
|
||||
merge-stream@2.0.0: {}
|
||||
|
||||
@@ -19,7 +19,6 @@ 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,
|
||||
@@ -35,7 +34,6 @@ describe('AppComponent', () => {
|
||||
let toastService: ToastService
|
||||
let router: Router
|
||||
let settingsService: SettingsService
|
||||
let tasksService: TasksService
|
||||
let hotKeyService: HotKeyService
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -62,7 +60,6 @@ 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)
|
||||
@@ -120,33 +117,30 @@ describe('AppComponent', () => {
|
||||
expect(toast.action).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should only reload tasks when a document is detected', () => {
|
||||
it('should display toast on document added', () => {
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
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(reloadSpy).toHaveBeenCalled()
|
||||
expect(toastSpy).not.toHaveBeenCalled()
|
||||
expect(toastSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should add consumed documents to the bell without a popup', () => {
|
||||
it('should suppress dashboard notifications if set', () => {
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
const popupSpy = jest.fn()
|
||||
toastService.showToast.subscribe(popupSpy)
|
||||
let toasts: Toast[] = []
|
||||
toastService.getToasts().subscribe((t) => (toasts = [...t]))
|
||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
||||
jest.spyOn(router, 'url', 'get').mockReturnValue('/dashboard')
|
||||
const toastSpy = jest.spyOn(toastService, 'show')
|
||||
const fileStatusSubject = new Subject<FileStatus>()
|
||||
jest
|
||||
.spyOn(websocketStatusService, 'onDocumentConsumptionFinished')
|
||||
.spyOn(websocketStatusService, 'onDocumentDetected')
|
||||
.mockReturnValue(fileStatusSubject)
|
||||
component.ngOnInit()
|
||||
fileStatusSubject.next(new FileStatus())
|
||||
expect(toasts).toHaveLength(1)
|
||||
expect(popupSpy).not.toHaveBeenCalled()
|
||||
expect(toastSpy).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 { ToastLevel, ToastService } from './services/toast.service'
|
||||
import { ToastService } from './services/toast.service'
|
||||
import { WebsocketStatusService } from './services/websocket-status.service'
|
||||
|
||||
@Component({
|
||||
@@ -61,6 +61,18 @@ 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()
|
||||
|
||||
@@ -68,22 +80,29 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
.onDocumentConsumptionFinished()
|
||||
.subscribe((status) => {
|
||||
this.tasksService.reload()
|
||||
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,
|
||||
})
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -91,7 +110,9 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
.onDocumentConsumptionFailed()
|
||||
.subscribe((status) => {
|
||||
this.tasksService.reload()
|
||||
if (this.settings.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED)) {
|
||||
if (
|
||||
this.showNotification(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED)
|
||||
) {
|
||||
this.toastService.showError(
|
||||
$localize`Could not add ${status.filename}\: ${status.message}`
|
||||
)
|
||||
@@ -100,8 +121,18 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
|
||||
this.newDocumentSubscription = this.websocketStatusService
|
||||
.onDocumentDetected()
|
||||
.subscribe(() => {
|
||||
.subscribe((status) => {
|
||||
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.showSuccess($localize`Configuration updated`)
|
||||
this.toastService.showInfo($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.showSuccess($localize`File successfully updated`)
|
||||
this.toastService.showInfo($localize`File successfully updated`)
|
||||
},
|
||||
error: (e) => {
|
||||
this.loading.set(false)
|
||||
|
||||
@@ -425,8 +425,10 @@
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<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 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="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(33)
|
||||
expect(setSpy).toHaveBeenCalledTimes(35)
|
||||
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, ToastLevel, ToastService } from 'src/app/services/toast.service'
|
||||
import { Toast, 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,8 +190,10 @@ 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),
|
||||
@@ -369,12 +371,18 @@ 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
|
||||
),
|
||||
@@ -563,6 +571,10 @@ 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
|
||||
@@ -571,6 +583,10 @@ 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
|
||||
@@ -648,11 +664,8 @@ 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,16 +99,11 @@ 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 { ToastLevel, ToastService } from 'src/app/services/toast.service'
|
||||
import { 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,11 +87,9 @@ export class TrashComponent
|
||||
modal.componentInstance.buttonsEnabled.set(false)
|
||||
this.trashService.emptyTrash([document.id]).subscribe({
|
||||
next: () => {
|
||||
this.toastService.show({
|
||||
content: $localize`Document "${document.title}" deleted`,
|
||||
delay: 5000,
|
||||
level: ToastLevel.Success,
|
||||
})
|
||||
this.toastService.showInfo(
|
||||
$localize`Document "${document.title}" deleted`
|
||||
)
|
||||
modal.close()
|
||||
this.reload()
|
||||
},
|
||||
@@ -124,11 +122,7 @@ export class TrashComponent
|
||||
.emptyTrash(documents ? Array.from(documents) : null)
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.toastService.show({
|
||||
content: $localize`Document(s) deleted`,
|
||||
delay: 5000,
|
||||
level: ToastLevel.Success,
|
||||
})
|
||||
this.toastService.showInfo($localize`Document(s) deleted`)
|
||||
this.allToggled.set(false)
|
||||
modal.close()
|
||||
this.reload()
|
||||
@@ -150,7 +144,6 @@ 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])
|
||||
@@ -172,11 +165,7 @@ export class TrashComponent
|
||||
.restoreDocuments(documents ? Array.from(documents) : null)
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.toastService.show({
|
||||
content: $localize`Document(s) restored`,
|
||||
delay: 5000,
|
||||
level: ToastLevel.Success,
|
||||
})
|
||||
this.toastService.showInfo($localize`Document(s) restored`)
|
||||
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, 'showSuccess')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
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, 'showSuccess')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
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, 'showSuccess')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
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, 'showSuccess')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
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.showSuccess(
|
||||
this.toastService.showInfo(
|
||||
$localize`Saved user "${newUser.username}".`
|
||||
)
|
||||
this.usersService.listAll().subscribe((r) => {
|
||||
@@ -151,9 +151,7 @@ export class UsersAndGroupsComponent
|
||||
this.usersService.delete(user).subscribe({
|
||||
next: () => {
|
||||
modal.close()
|
||||
this.toastService.showSuccess(
|
||||
$localize`Deleted user "${user.username}"`
|
||||
)
|
||||
this.toastService.showInfo($localize`Deleted user "${user.username}"`)
|
||||
this.usersService.listAll().subscribe((r) => {
|
||||
this.users.set(r.results)
|
||||
})
|
||||
@@ -180,9 +178,7 @@ export class UsersAndGroupsComponent
|
||||
modal.componentInstance.succeeded
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((newGroup) => {
|
||||
this.toastService.showSuccess(
|
||||
$localize`Saved group "${newGroup.name}".`
|
||||
)
|
||||
this.toastService.showInfo($localize`Saved group "${newGroup.name}".`)
|
||||
this.groupsService.listAll().subscribe((r) => {
|
||||
this.groups.set(r.results)
|
||||
})
|
||||
@@ -208,9 +204,7 @@ export class UsersAndGroupsComponent
|
||||
this.groupsService.delete(group).subscribe({
|
||||
next: () => {
|
||||
modal.close()
|
||||
this.toastService.showSuccess(
|
||||
$localize`Deleted group "${group.name}"`
|
||||
)
|
||||
this.toastService.showInfo($localize`Deleted group "${group.name}"`)
|
||||
this.groupsService.listAll().subscribe((r) => {
|
||||
this.groups.set(r.results)
|
||||
})
|
||||
|
||||
@@ -29,20 +29,8 @@
|
||||
</div>
|
||||
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 me-sm-auto order-3 order-sm-1"
|
||||
[class.mobile-hidden]="mobileSearchHidden()">
|
||||
<div class="col-12 header-search mx-auto d-flex align-items-stretch gap-3">
|
||||
<pngx-global-search class="flex-grow-1"></pngx-global-search>
|
||||
<div *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Document }" class="btn-group header-upload-group flex-shrink-0">
|
||||
<button class="btn btn-sm header-upload border-0 d-flex align-items-center justify-content-center py-0 px-2" type="button"
|
||||
(click)="fileUpload.click()" aria-label="Upload documents" i18n-aria-label>
|
||||
<i-bs class="d-flex" width="1.1em" height="1.1em" name="file-earmark-arrow-up"></i-bs><span class="ms-2 d-sm-none d-md-inline" i18n>Upload</span>
|
||||
</button>
|
||||
@if (uploadsInProgress > 0) {
|
||||
<button class="btn btn-sm header-upload border-0 d-flex align-items-center py-0 px-2" type="button"
|
||||
(click)="showUploadStatus()" aria-label="Show document processing status" i18n-aria-label>
|
||||
<span class="spinner-border spinner-border-sm" aria-hidden="true"></span><span class="ms-2 small">{{ uploadsInProgress }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="col-12 header-search mx-auto">
|
||||
<pngx-global-search></pngx-global-search>
|
||||
</div>
|
||||
</div>
|
||||
<ul ngbNav class="order-sm-3">
|
||||
@@ -82,9 +70,6 @@
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<input type="file" class="visually-hidden" id="navbar-file-upload" (change)="onFileSelected($event)" multiple #fileUpload tabindex="-1" aria-hidden="true">
|
||||
<label for="navbar-file-upload" class="visually-hidden" i18n>Upload Files</label>
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse"
|
||||
@@ -434,6 +419,5 @@
|
||||
[ngClass]="slimSidebarEnabled ? 'col-slim' : 'col-sidebar-expanded'">
|
||||
<router-outlet></router-outlet>
|
||||
</main>
|
||||
<pngx-upload-status-tray></pngx-upload-status-tray>
|
||||
</div>
|
||||
</div>
|
||||
@@ -538,36 +538,7 @@ main {
|
||||
|
||||
.header-search {
|
||||
width: 100%;
|
||||
max-width: 50rem;
|
||||
}
|
||||
|
||||
.header-search pngx-global-search {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// light w shadow to stand out against search
|
||||
.header-upload {
|
||||
--bs-btn-bg: rgba(255, 255, 255, .15);
|
||||
--bs-btn-hover-bg: rgba(255, 255, 255, .22);
|
||||
--bs-btn-active-bg: rgba(255, 255, 255, .3);
|
||||
color: var(--pngx-primary-text-contrast) !important; // .navbar .dropdown > .btn in styles.scss
|
||||
|
||||
& + & {
|
||||
border-left: 1px solid rgba(255, 255, 255, .2) !important;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, .5);
|
||||
}
|
||||
}
|
||||
|
||||
.header-upload-group {
|
||||
border-radius: var(--bs-border-radius-sm);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, .35), 0 1px 1px rgba(0, 0, 0, .2);
|
||||
max-width: 55rem;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 575.98px) {
|
||||
@@ -659,7 +630,8 @@ main {
|
||||
}
|
||||
|
||||
.dropdown-toggle::after {
|
||||
display: none;
|
||||
margin-left: 0.4em;
|
||||
vertical-align: 0.155em;
|
||||
}
|
||||
|
||||
.navbar .dropdown-menu {
|
||||
@@ -696,7 +668,3 @@ main {
|
||||
::ng-deep .navItemDrag .position-absolute i-bs {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.smaller {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from '@angular/common/http/testing'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { BrowserModule, By } from '@angular/platform-browser'
|
||||
import { BrowserModule } from '@angular/platform-browser'
|
||||
import { ActivatedRoute, Router } from '@angular/router'
|
||||
import { RouterTestingModule } from '@angular/router/testing'
|
||||
import { NgbModal, NgbModalModule, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
@@ -33,11 +33,8 @@ import { SearchService } from 'src/app/services/rest/search.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { TasksService } from 'src/app/services/tasks.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
|
||||
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
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 { AppFrameComponent } from './app-frame.component'
|
||||
import { GlobalSearchComponent } from './global-search/global-search.component'
|
||||
@@ -465,45 +462,6 @@ describe('AppFrameComponent', () => {
|
||||
expect(routerSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should upload files selected from the navbar upload button', () => {
|
||||
const uploadDocumentsService = TestBed.inject(UploadDocumentsService)
|
||||
const uploadSpy = jest
|
||||
.spyOn(uploadDocumentsService, 'uploadFiles')
|
||||
.mockImplementation(() => {})
|
||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||
const file = new File(['testing'], 'file.pdf')
|
||||
const input = window.document.createElement('input')
|
||||
jest.spyOn(input, 'files', 'get').mockReturnValue([file] as any)
|
||||
|
||||
component.onFileSelected({ target: input } as unknown as Event)
|
||||
expect(toastSpy).not.toHaveBeenCalled()
|
||||
expect(uploadSpy).toHaveBeenCalledWith([file])
|
||||
})
|
||||
|
||||
it('should show in-progress uploads and expand the status tray', () => {
|
||||
const websocketStatusService = TestBed.inject(WebsocketStatusService)
|
||||
expect(component.uploadsInProgress).toEqual(0)
|
||||
websocketStatusService.newFileUpload('file.pdf')
|
||||
expect(component.uploadsInProgress).toEqual(1)
|
||||
|
||||
fixture.detectChanges()
|
||||
const tray = fixture.debugElement.query(
|
||||
By.directive(UploadStatusTrayComponent)
|
||||
).componentInstance as UploadStatusTrayComponent
|
||||
tray.toggleCollapsed()
|
||||
expect(tray.collapsed()).toBe(true)
|
||||
component.showUploadStatus()
|
||||
expect(tray.collapsed()).toBe(false)
|
||||
})
|
||||
|
||||
it('should not upload when no files are selected from the navbar', () => {
|
||||
const uploadDocumentsService = TestBed.inject(UploadDocumentsService)
|
||||
const uploadSpy = jest.spyOn(uploadDocumentsService, 'uploadFiles')
|
||||
const input = window.document.createElement('input')
|
||||
component.onFileSelected({ target: input } as unknown as Event)
|
||||
expect(uploadSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should close all documents on logout', () => {
|
||||
const closeAllSpy = jest.spyOn(openDocumentsService, 'closeAll')
|
||||
component.onLogout()
|
||||
@@ -525,7 +483,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, 'showSuccess')
|
||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
|
||||
component.onDrop({ previousIndex: 0, currentIndex: 1 } as CdkDragDrop<
|
||||
SavedView[]
|
||||
@@ -567,7 +525,6 @@ 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' },
|
||||
@@ -578,8 +535,7 @@ describe('AppFrameComponent', () => {
|
||||
])
|
||||
component.ngOnInit()
|
||||
expect(toastErrorSpy).toHaveBeenCalledTimes(2)
|
||||
expect(toastSuccessSpy).toHaveBeenCalledTimes(1)
|
||||
expect(toastInfoSpy).toHaveBeenCalledTimes(2)
|
||||
expect(toastInfoSpy).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('should call maybeRefreshDocumentCounts after saved views reload', () => {
|
||||
|
||||
@@ -6,14 +6,7 @@ import {
|
||||
moveItemInArray,
|
||||
} from '@angular/cdk/drag-drop'
|
||||
import { NgClass } from '@angular/common'
|
||||
import {
|
||||
Component,
|
||||
HostListener,
|
||||
inject,
|
||||
OnInit,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core'
|
||||
import { Component, HostListener, inject, OnInit, signal } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
|
||||
import {
|
||||
@@ -55,15 +48,12 @@ import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { TasksService } from 'src/app/services/tasks.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
|
||||
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { ChatComponent } from '../chat/chat/chat.component'
|
||||
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 { SwitchComponent } from '../common/input/switch/switch.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'
|
||||
@@ -94,7 +84,6 @@ const SCROLL_THRESHOLD = 16
|
||||
TourNgBootstrap,
|
||||
FormsModule,
|
||||
SwitchComponent,
|
||||
UploadStatusTrayComponent,
|
||||
],
|
||||
})
|
||||
export class AppFrameComponent
|
||||
@@ -112,10 +101,6 @@ export class AppFrameComponent
|
||||
private modalService = inject(NgbModal)
|
||||
permissionsService = inject(PermissionsService)
|
||||
private djangoMessagesService = inject(DjangoMessagesService)
|
||||
private uploadDocumentsService = inject(UploadDocumentsService)
|
||||
private websocketStatusService = inject(WebsocketStatusService)
|
||||
|
||||
private readonly uploadStatusTray = viewChild(UploadStatusTrayComponent)
|
||||
|
||||
readonly appRemoteVersion = signal<AppRemoteVersion>(null)
|
||||
readonly isMenuCollapsed = signal(true)
|
||||
@@ -186,8 +171,6 @@ 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)
|
||||
@@ -494,7 +477,7 @@ export class AppFrameComponent
|
||||
|
||||
this.settingsService.updateSidebarViewsSort(sidebarViews).subscribe({
|
||||
next: () => {
|
||||
this.toastService.showSuccess($localize`Sidebar views updated`)
|
||||
this.toastService.showInfo($localize`Sidebar views updated`)
|
||||
},
|
||||
error: (e) => {
|
||||
this.toastService.showError($localize`Error updating sidebar views`, e)
|
||||
@@ -528,22 +511,6 @@ export class AppFrameComponent
|
||||
}
|
||||
}
|
||||
|
||||
onFileSelected(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = Array.from(input.files ?? [])
|
||||
input.value = ''
|
||||
if (!files.length) return
|
||||
this.uploadDocumentsService.uploadFiles(files)
|
||||
}
|
||||
|
||||
get uploadsInProgress(): number {
|
||||
return this.websocketStatusService.getConsumerStatusNotCompleted().length
|
||||
}
|
||||
|
||||
showUploadStatus() {
|
||||
this.uploadStatusTray()?.expand()
|
||||
}
|
||||
|
||||
onLogout() {
|
||||
this.openDocumentsService.closeAll()
|
||||
}
|
||||
|
||||
@@ -409,7 +409,7 @@ describe('GlobalSearchComponent', () => {
|
||||
|
||||
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
|
||||
// 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, 'showSuccess')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
|
||||
// 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.showSuccess($localize`Successfully updated object.`)
|
||||
this.toastService.showInfo($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.showSuccess($localize`Successfully updated object.`)
|
||||
this.toastService.showInfo($localize`Successfully updated object.`)
|
||||
})
|
||||
modalRef.componentInstance.failed.subscribe((e) => {
|
||||
this.toastService.showError($localize`Error occurred saving object.`, e)
|
||||
|
||||
+15
-18
@@ -1,31 +1,28 @@
|
||||
|
||||
<li ngbDropdown class="nav-item position-relative" (openChange)="onOpenChange($event)">
|
||||
@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>
|
||||
@if (toasts().length) {
|
||||
<span class="notification-count badge rounded-pill z-3 pe-none bg-secondary position-absolute">{{ toasts().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-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 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>
|
||||
@if (toasts().length === 0) {
|
||||
<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>
|
||||
<p class="text-center mb-0 small text-muted"><em i18n>No notifications</em></p>
|
||||
}
|
||||
<div class="scroll-list d-flex flex-column gap-2">
|
||||
<div class="scroll-list">
|
||||
@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>
|
||||
<pngx-toast [autohide]="false" [toast]="toast" (hidden)="onHidden(toast)" (closed)="toastService.closeToast(toast)"></pngx-toast>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
@@ -30,7 +30,3 @@
|
||||
right: -3rem;
|
||||
}
|
||||
}
|
||||
|
||||
.clear-all:hover {
|
||||
color: var(--bs-body-color) !important;
|
||||
}
|
||||
+1
-70
@@ -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, ToastLevel, ToastService } from 'src/app/services/toast.service'
|
||||
import { Toast, ToastService } from 'src/app/services/toast.service'
|
||||
import { ToastsDropdownComponent } from './toasts-dropdown.component'
|
||||
|
||||
const toasts = [
|
||||
@@ -90,73 +90,4 @@ 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,20 +1,12 @@
|
||||
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 { Subscription, interval, map } from 'rxjs'
|
||||
import { Toast, ToastLevel, ToastService } from 'src/app/services/toast.service'
|
||||
import { map } from 'rxjs'
|
||||
import { Toast, ToastService } from 'src/app/services/toast.service'
|
||||
import { ToastComponent } from '../../common/toast/toast.component'
|
||||
|
||||
@Component({
|
||||
@@ -30,44 +22,13 @@ 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,85 +1,49 @@
|
||||
|
||||
<li ngbDropdown class="nav-item" display="dynamic" placement="bottom-end" [popperOptions]="popperOptions" (openChange)="onOpenChange($event)">
|
||||
<li ngbDropdown class="nav-item" (openChange)="onOpenChange($event)">
|
||||
<button class="btn navbar-action border-0" id="chatDropdown" ngbDropdownToggle aria-label="Chat" i18n-aria-label>
|
||||
<i-bs width="1.3em" height="1.3em" name="chatSquareDots"></i-bs>
|
||||
</button>
|
||||
<div ngbDropdownMenu class="dropdown-menu-end shadow p-0 overflow-hidden" aria-labelledby="chatDropdown">
|
||||
<div class="d-flex align-items-center border-bottom px-3 py-2">
|
||||
<i-bs width="1em" height="1em" name="stars" class="text-primary me-2"></i-bs>
|
||||
<span class="fw-semibold small">
|
||||
@if (documentId()) {
|
||||
<ng-container i18n>Ask about this document</ng-container>
|
||||
} @else {
|
||||
<ng-container i18n>Ask about your documents</ng-container>
|
||||
}
|
||||
</span>
|
||||
@if (messages().length) {
|
||||
<button class="btn btn-sm btn-link text-muted ms-auto p-0" type="button" (click)="clearChat()" title="Clear chat" i18n-title>
|
||||
<i-bs width="1em" height="1em" name="arrow-counterclockwise"></i-bs>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="chat-messages small px-2">
|
||||
@if (!messages().length) {
|
||||
<p class="text-muted text-center py-4 px-3 mb-0" i18n>Answers come from your documents, with links to their sources.</p>
|
||||
}
|
||||
@for (message of messages(); track message) {
|
||||
<div class="message d-flex flex-row" [class.justify-content-end]="message.role === 'user'" [class.mt-3]="message.role === 'user' && !$first">
|
||||
<div class="p-2 m-2" [class.bg-body-secondary]="message.role === 'user'" [class.rounded-3]="message.role === 'user'" [class.flex-grow-1]="message.role === 'assistant'">
|
||||
@if (message.isStreaming && !message.content) {
|
||||
<div class="d-flex align-items-center text-muted">
|
||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||
@if (slowResponse()) {
|
||||
<ng-container i18n>Still thinking...</ng-container>
|
||||
} @else {
|
||||
<ng-container i18n>Thinking...</ng-container>
|
||||
}
|
||||
</div>
|
||||
} @else if (message.role === 'assistant') {
|
||||
<div class="chat-markdown text-break text-wrap" [class.streaming]="message.isStreaming" [innerHTML]="message.content | markdown"></div>
|
||||
} @else {
|
||||
<span class="text-break">{{ message.content }}</span>
|
||||
}
|
||||
@if (message.role === 'assistant' && !message.isStreaming && message.references?.length) {
|
||||
<div class="chat-references list-group mt-3">
|
||||
<div class="list-group-item text-muted small py-1" i18n>Sources</div>
|
||||
@for (reference of message.references; track reference.id) {
|
||||
<a class="list-group-item list-group-item-action d-flex align-items-center text-primary" [routerLink]="['/documents', reference.id]" [title]="reference.title">
|
||||
<i-bs width="0.9em" height="0.9em" name="file-text" class="me-2 flex-shrink-0"></i-bs><span class="text-truncate">{{ reference.title }}</span>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown">
|
||||
<div class="chat-container bg-light p-2">
|
||||
<div class="chat-messages font-monospace small">
|
||||
@for (message of messages(); track message) {
|
||||
<div class="message d-flex flex-row small" [class.justify-content-end]="message.role === 'user'">
|
||||
<div class="p-2 m-2" [class.bg-body]="message.role === 'user'">
|
||||
<span class="text-break">
|
||||
{{ message.content }}
|
||||
@if (message.isStreaming) { <span class="blinking-cursor">|</span> }
|
||||
</span>
|
||||
@if (message.role === 'assistant' && message.references?.length) {
|
||||
<div class="chat-references list-group mt-3">
|
||||
@for (reference of message.references; track reference.id) {
|
||||
<a class="list-group-item list-group-item-action d-flex text-primary text-break" [routerLink]="['/documents', reference.id]">
|
||||
<i-bs width="0.9em" height="0.9em" name="file-text" class="me-1"></i-bs><span>{{ reference.title }}</span>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div #scrollAnchor></div>
|
||||
</div>
|
||||
}
|
||||
<div #scrollAnchor></div>
|
||||
</div>
|
||||
|
||||
<form class="chat-input border-top p-2">
|
||||
<label class="visually-hidden" for="chatInput" i18n>Message</label>
|
||||
<div class="input-group">
|
||||
<input
|
||||
#chatInput
|
||||
id="chatInput"
|
||||
class="form-control" name="chatInput" type="text"
|
||||
<form class="chat-input">
|
||||
<label class="visually-hidden" for="chatInput" i18n>Message</label>
|
||||
<div class="input-group">
|
||||
<input
|
||||
#chatInput
|
||||
id="chatInput"
|
||||
class="form-control form-control-sm" name="chatInput" type="text"
|
||||
[placeholder]="placeholder"
|
||||
[disabled]="loading()"
|
||||
[ngModel]="input()"
|
||||
(ngModelChange)="input.set($event)"
|
||||
(keydown)="searchInputKeyDown($event)"
|
||||
/>
|
||||
@if (loading()) {
|
||||
<button class="btn btn-outline-secondary" type="button" (click)="stopStreaming()" title="Stop" i18n-title aria-label="Stop" i18n-aria-label>
|
||||
<i-bs width="1em" height="1em" name="x-lg"></i-bs>
|
||||
</button>
|
||||
} @else {
|
||||
<button class="btn btn-primary" type="button" (click)="sendMessage()" title="Send" i18n-title aria-label="Send" i18n-aria-label>
|
||||
<i-bs width="1em" height="1em" name="arrow-return-left"></i-bs>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
/>
|
||||
<button class="btn btn-sm btn-secondary" type="button" (click)="sendMessage()" [disabled]="loading()">Send</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
@@ -1,13 +1,17 @@
|
||||
.dropdown-menu {
|
||||
width: min(36rem, calc(100vw - 20px));
|
||||
width: var(--pngx-toast-max-width);
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
max-height: min(60dvh, 36rem);
|
||||
max-height: 350px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.chat-references {
|
||||
font-family: var(--bs-font-sans-serif);
|
||||
}
|
||||
|
||||
.dropdown-toggle::after {
|
||||
display: none;
|
||||
}
|
||||
@@ -16,44 +20,23 @@
|
||||
white-space: initial;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 575.98px) {
|
||||
.chat-messages {
|
||||
max-height: calc(100dvh - 12rem);
|
||||
@media screen and (max-width: 400px) {
|
||||
:host ::ng-deep .dropdown-menu-end {
|
||||
right: -3rem;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-markdown.streaming ::ng-deep {
|
||||
> :last-child:not(ul, ol, table)::after,
|
||||
> :is(ul, ol):last-child > li:last-child::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
height: 1.05em;
|
||||
margin-left: 2px;
|
||||
background-color: currentColor;
|
||||
vertical-align: -0.15em;
|
||||
animation: blink 1s step-end infinite;
|
||||
}
|
||||
.blinking-cursor {
|
||||
font-weight: bold;
|
||||
font-size: 1.2em;
|
||||
animation: blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
50% {
|
||||
from, to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-markdown ::ng-deep {
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0.15rem 0.4rem;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
> :last-child {
|
||||
margin-bottom: 0;
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -80,16 +80,8 @@ describe('ChatComponent', () => {
|
||||
expect(component.messages()[0].content).toBe('Hello')
|
||||
expect(component.loading()).toBe(true)
|
||||
|
||||
fixture.detectChanges()
|
||||
const messagesEl = fixture.nativeElement.querySelector('.chat-messages')
|
||||
expect(messagesEl.querySelector('.spinner-border')).not.toBeNull()
|
||||
expect(messagesEl.textContent).toContain('Thinking...')
|
||||
|
||||
mockStream$.next('Hi')
|
||||
expect(component.messages()[1].content).toBe('H')
|
||||
fixture.detectChanges()
|
||||
expect(messagesEl.querySelector('.spinner-border')).toBeNull()
|
||||
expect(messagesEl.querySelector('.chat-markdown.streaming')).not.toBeNull()
|
||||
mockStream$.next('Hi there')
|
||||
// advance time to process the typewriter effect
|
||||
await jest.runAllTimersAsync()
|
||||
@@ -104,8 +96,6 @@ describe('ChatComponent', () => {
|
||||
await fixture.whenStable()
|
||||
expect(component.loading()).toBe(false)
|
||||
expect(component.messages()[1].isStreaming).toBe(false)
|
||||
fixture.detectChanges()
|
||||
expect(messagesEl.querySelector('.chat-markdown.streaming')).toBeNull()
|
||||
expect(fixture.nativeElement.querySelector('#chatInput').disabled).toBe(
|
||||
false
|
||||
)
|
||||
@@ -133,10 +123,6 @@ describe('ChatComponent', () => {
|
||||
mockStream$.next(
|
||||
`Hi there${CHAT_METADATA_DELIMITER}{"references":[{"id":42,"title":"Bread Recipe"}]}`
|
||||
)
|
||||
fixture.detectChanges()
|
||||
expect(fixture.nativeElement.querySelector('.chat-references')).toBeNull()
|
||||
|
||||
mockStream$.complete()
|
||||
jest.advanceTimersByTime(1000)
|
||||
fixture.detectChanges()
|
||||
|
||||
@@ -170,96 +156,13 @@ describe('ChatComponent', () => {
|
||||
component.input.set('Hello')
|
||||
component.sendMessage()
|
||||
|
||||
mockStream$.next('Partial')
|
||||
mockStream$.error('Error')
|
||||
expect(component.messages()[1].content).toBe(
|
||||
'Partial\n\n⚠️ Error receiving response.'
|
||||
expect(component.messages()[1].content).toContain(
|
||||
'⚠️ Error receiving response.'
|
||||
)
|
||||
expect(component.loading()).toBe(false)
|
||||
})
|
||||
|
||||
it('should keep streaming until the typewriter catches up', () => {
|
||||
component.input.set('Hello')
|
||||
component.sendMessage()
|
||||
|
||||
mockStream$.next('Hi there')
|
||||
mockStream$.complete()
|
||||
expect(component.messages()[1].isStreaming).toBe(true)
|
||||
expect(component.loading()).toBe(true)
|
||||
|
||||
jest.advanceTimersByTime(1000)
|
||||
expect(component.messages()[1].content).toBe('Hi there')
|
||||
expect(component.messages()[1].isStreaming).toBe(false)
|
||||
expect(component.loading()).toBe(false)
|
||||
})
|
||||
|
||||
it('should show a slow response message while waiting', () => {
|
||||
component.input.set('Hello')
|
||||
component.sendMessage()
|
||||
fixture.detectChanges()
|
||||
const messagesEl = fixture.nativeElement.querySelector('.chat-messages')
|
||||
expect(messagesEl.textContent).not.toContain('Still thinking...')
|
||||
|
||||
jest.advanceTimersByTime(8000)
|
||||
fixture.detectChanges()
|
||||
expect(messagesEl.textContent).toContain('Still thinking...')
|
||||
|
||||
mockStream$.next('Hi')
|
||||
expect(component.slowResponse()).toBe(false)
|
||||
})
|
||||
|
||||
it('should stop streaming and keep the partial response', () => {
|
||||
component.input.set('Hello')
|
||||
component.sendMessage()
|
||||
fixture.detectChanges()
|
||||
const stopButton = fixture.nativeElement.querySelector('.chat-input button')
|
||||
expect(stopButton.getAttribute('aria-label')).toBe('Stop')
|
||||
|
||||
mockStream$.next('Hi there')
|
||||
jest.advanceTimersByTime(20)
|
||||
stopButton.click()
|
||||
|
||||
expect(mockStream$.observed).toBe(false)
|
||||
expect(component.messages()[1].content).toBe('Hi there')
|
||||
expect(component.messages()[1].isStreaming).toBe(false)
|
||||
expect(component.loading()).toBe(false)
|
||||
|
||||
jest.advanceTimersByTime(1000)
|
||||
expect(component.messages()[1].content).toBe('Hi there')
|
||||
})
|
||||
|
||||
it('should drop the empty response when stopped before any content', () => {
|
||||
component.input.set('Hello')
|
||||
component.sendMessage()
|
||||
component.stopStreaming()
|
||||
|
||||
expect(component.messages()).toHaveLength(1)
|
||||
expect(component.loading()).toBe(false)
|
||||
component.stopStreaming() // no-op once stopped
|
||||
expect(component.messages()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should clear the conversation', () => {
|
||||
component.input.set('Hello')
|
||||
component.sendMessage()
|
||||
mockStream$.next('Hi there')
|
||||
fixture.detectChanges()
|
||||
|
||||
fixture.nativeElement.querySelector('button[title="Clear chat"]').click()
|
||||
expect(component.messages()).toHaveLength(0)
|
||||
expect(component.loading()).toBe(false)
|
||||
expect(mockStream$.observed).toBe(false)
|
||||
|
||||
fixture.detectChanges()
|
||||
jest.advanceTimersByTime(10)
|
||||
expect(document.activeElement).toBe(
|
||||
fixture.nativeElement.querySelector('#chatInput')
|
||||
)
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'Answers come from your documents'
|
||||
)
|
||||
})
|
||||
|
||||
it('should enqueue typewriter chunks correctly', () => {
|
||||
const message = { content: '', role: 'assistant', isStreaming: true }
|
||||
component.enqueueTypewriter(null, message as any) // coverage for null
|
||||
@@ -304,33 +207,4 @@ describe('ChatComponent', () => {
|
||||
component.searchInputKeyDown(event)
|
||||
expect(component.sendMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render markdown for assistant messages only', () => {
|
||||
component.messages.set([
|
||||
{ role: 'user', content: '**user**' },
|
||||
{ role: 'assistant', content: '**assistant** <script>x</script>' },
|
||||
])
|
||||
fixture.detectChanges()
|
||||
const el: HTMLElement = fixture.nativeElement
|
||||
expect(el.querySelector('.chat-markdown strong')?.textContent).toBe(
|
||||
'assistant'
|
||||
)
|
||||
expect(el.querySelector('.chat-markdown script')).toBeNull()
|
||||
expect(el.textContent).toContain('**user**')
|
||||
})
|
||||
|
||||
it('should keep the destination hostname visible after sanitizing chat links', () => {
|
||||
component.messages.set([
|
||||
{
|
||||
role: 'assistant',
|
||||
content:
|
||||
'[View original document](https://otherdomain.example/?data=secret)',
|
||||
},
|
||||
])
|
||||
fixture.detectChanges()
|
||||
const el: HTMLElement = fixture.nativeElement
|
||||
const link = el.querySelector('.chat-markdown a')
|
||||
expect(link?.textContent).toBe('View original document')
|
||||
expect(link?.nextElementSibling?.textContent).toBe('(otherdomain.example)')
|
||||
})
|
||||
})
|
||||
@@ -10,16 +10,12 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { NavigationEnd, Router, RouterModule } from '@angular/router'
|
||||
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { filter, map, Subscription } from 'rxjs'
|
||||
import { MarkdownPipe } from 'src/app/pipes/markdown.pipe'
|
||||
import { filter, map } from 'rxjs'
|
||||
import {
|
||||
ChatMessage,
|
||||
ChatService,
|
||||
parseChatResponse,
|
||||
} from 'src/app/services/chat.service'
|
||||
import { pngxPopperOptions } from 'src/app/utils/popper-options'
|
||||
|
||||
const SLOW_RESPONSE_DELAY_MS = 8000
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-chat',
|
||||
@@ -29,7 +25,6 @@ const SLOW_RESPONSE_DELAY_MS = 8000
|
||||
RouterModule,
|
||||
NgxBootstrapIconsModule,
|
||||
NgbDropdownModule,
|
||||
MarkdownPipe,
|
||||
],
|
||||
templateUrl: './chat.component.html',
|
||||
styleUrl: './chat.component.scss',
|
||||
@@ -39,8 +34,6 @@ export class ChatComponent implements OnInit {
|
||||
readonly loading = signal(false)
|
||||
readonly input = signal('')
|
||||
readonly documentId = signal<number>(undefined)
|
||||
readonly slowResponse = signal(false)
|
||||
public popperOptions = pngxPopperOptions
|
||||
|
||||
private chatService: ChatService = inject(ChatService)
|
||||
private router: Router = inject(Router)
|
||||
@@ -50,11 +43,6 @@ export class ChatComponent implements OnInit {
|
||||
|
||||
private typewriterBuffer: string[] = []
|
||||
private typewriterActive = false
|
||||
private typewriterTimeout: ReturnType<typeof setTimeout>
|
||||
private streamSubscription: Subscription
|
||||
private streamingMessage: ChatMessage
|
||||
private streamFinished = false
|
||||
private slowResponseTimeout: ReturnType<typeof setTimeout>
|
||||
|
||||
public get placeholder(): string {
|
||||
return this.documentId()
|
||||
@@ -93,96 +81,47 @@ export class ChatComponent implements OnInit {
|
||||
}
|
||||
this.messages.update((messages) => [...messages, assistantMessage])
|
||||
this.loading.set(true)
|
||||
this.streamingMessage = assistantMessage
|
||||
this.streamFinished = false
|
||||
this.slowResponseTimeout = setTimeout(
|
||||
() => this.slowResponse.set(true),
|
||||
SLOW_RESPONSE_DELAY_MS
|
||||
)
|
||||
|
||||
let lastVisibleContent = ''
|
||||
|
||||
this.streamSubscription = this.chatService
|
||||
.streamChat(this.documentId(), this.input())
|
||||
.subscribe({
|
||||
next: (chunk) => {
|
||||
this.clearSlowResponse()
|
||||
const nextResponse = parseChatResponse(chunk)
|
||||
this.chatService.streamChat(this.documentId(), this.input()).subscribe({
|
||||
next: (chunk) => {
|
||||
const nextResponse = parseChatResponse(chunk)
|
||||
|
||||
if (nextResponse.content.length < lastVisibleContent.length) {
|
||||
this.resetTypewriter(assistantMessage, nextResponse.content)
|
||||
lastVisibleContent = nextResponse.content
|
||||
} else {
|
||||
const visibleDelta = nextResponse.content.substring(
|
||||
lastVisibleContent.length
|
||||
)
|
||||
lastVisibleContent = nextResponse.content
|
||||
this.enqueueTypewriter(visibleDelta, assistantMessage)
|
||||
}
|
||||
if (nextResponse.content.length < lastVisibleContent.length) {
|
||||
this.resetTypewriter(assistantMessage, nextResponse.content)
|
||||
lastVisibleContent = nextResponse.content
|
||||
} else {
|
||||
const visibleDelta = nextResponse.content.substring(
|
||||
lastVisibleContent.length
|
||||
)
|
||||
lastVisibleContent = nextResponse.content
|
||||
this.enqueueTypewriter(visibleDelta, assistantMessage)
|
||||
}
|
||||
|
||||
assistantMessage.references = nextResponse.references
|
||||
this.notifyMessagesChanged()
|
||||
},
|
||||
error: () => {
|
||||
this.flushTypewriter(assistantMessage)
|
||||
assistantMessage.content += '\n\n⚠️ Error receiving response.'
|
||||
this.finishStreaming(assistantMessage)
|
||||
},
|
||||
complete: () => {
|
||||
// let the typewriter catch up before hiding the cursor
|
||||
this.streamFinished = true
|
||||
if (!this.typewriterActive) this.finishStreaming(assistantMessage)
|
||||
},
|
||||
})
|
||||
assistantMessage.references = nextResponse.references
|
||||
this.notifyMessagesChanged()
|
||||
},
|
||||
error: () => {
|
||||
assistantMessage.content += '\n\n⚠️ Error receiving response.'
|
||||
assistantMessage.isStreaming = false
|
||||
this.notifyMessagesChanged()
|
||||
this.loading.set(false)
|
||||
},
|
||||
complete: () => {
|
||||
assistantMessage.isStreaming = false
|
||||
this.notifyMessagesChanged()
|
||||
this.loading.set(false)
|
||||
this.scrollToBottom()
|
||||
},
|
||||
})
|
||||
|
||||
this.input.set('')
|
||||
}
|
||||
|
||||
stopStreaming(): void {
|
||||
const message = this.streamingMessage
|
||||
if (!message?.isStreaming) return
|
||||
|
||||
this.streamSubscription?.unsubscribe()
|
||||
this.flushTypewriter(message)
|
||||
if (!message.content) {
|
||||
this.messages.update((messages) => messages.filter((m) => m !== message))
|
||||
}
|
||||
this.finishStreaming(message)
|
||||
}
|
||||
|
||||
clearChat(): void {
|
||||
this.stopStreaming()
|
||||
this.messages.set([])
|
||||
setTimeout(() => this.chatInput?.nativeElement.focus())
|
||||
}
|
||||
|
||||
private finishStreaming(message: ChatMessage): void {
|
||||
this.clearSlowResponse()
|
||||
this.streamFinished = false
|
||||
message.isStreaming = false
|
||||
this.notifyMessagesChanged()
|
||||
this.loading.set(false)
|
||||
this.scrollToBottom()
|
||||
}
|
||||
|
||||
private clearSlowResponse(): void {
|
||||
clearTimeout(this.slowResponseTimeout)
|
||||
this.slowResponse.set(false)
|
||||
}
|
||||
|
||||
private flushTypewriter(message: ChatMessage): void {
|
||||
message.content += this.typewriterBuffer.join('')
|
||||
this.cancelTypewriter()
|
||||
}
|
||||
|
||||
private cancelTypewriter(): void {
|
||||
clearTimeout(this.typewriterTimeout)
|
||||
private resetTypewriter(message: ChatMessage, content: string): void {
|
||||
this.typewriterBuffer = []
|
||||
this.typewriterActive = false
|
||||
}
|
||||
|
||||
private resetTypewriter(message: ChatMessage, content: string): void {
|
||||
this.cancelTypewriter()
|
||||
message.content = content
|
||||
this.notifyMessagesChanged()
|
||||
this.scrollToBottom()
|
||||
@@ -202,7 +141,6 @@ export class ChatComponent implements OnInit {
|
||||
playTypewriter(message: ChatMessage): void {
|
||||
if (this.typewriterBuffer.length === 0) {
|
||||
this.typewriterActive = false
|
||||
if (this.streamFinished) this.finishStreaming(message)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -211,8 +149,7 @@ export class ChatComponent implements OnInit {
|
||||
this.notifyMessagesChanged()
|
||||
this.scrollToBottom()
|
||||
|
||||
// 10ms per character
|
||||
this.typewriterTimeout = setTimeout(() => this.playTypewriter(message), 10)
|
||||
setTimeout(() => this.playTypewriter(message), 10) // 10ms per character
|
||||
}
|
||||
|
||||
private notifyMessagesChanged(): void {
|
||||
|
||||
+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, 'showSuccess')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
const getFieldsSpy = jest.spyOn(
|
||||
CustomFieldsDropdownComponent.prototype as any,
|
||||
'getFields'
|
||||
|
||||
+1
-3
@@ -129,9 +129,7 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio
|
||||
modal.componentInstance.succeeded
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((newField) => {
|
||||
this.toastService.showSuccess(
|
||||
$localize`Saved field "${newField.name}".`
|
||||
)
|
||||
this.toastService.showInfo($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, 'showSuccess')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
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.showSuccess($localize`Totp deactivated`)
|
||||
this.toastService.showInfo($localize`Totp deactivated`)
|
||||
this.object.is_mfa_enabled = false
|
||||
} else {
|
||||
this.toastService.showError($localize`Totp deactivation failed`)
|
||||
|
||||
+1
-2
@@ -209,7 +209,6 @@
|
||||
@if (isTagsFilter(filter.get('type').value)) {
|
||||
<pngx-input-tags
|
||||
[allowCreate]="false"
|
||||
[preserveHierarchy]="false"
|
||||
[title]="null"
|
||||
formControlName="values"
|
||||
></pngx-input-tags>
|
||||
@@ -321,7 +320,7 @@
|
||||
<h6 class="form-label" i18n>Remove tags</h6>
|
||||
<pngx-input-switch i18n-title title="Remove all" [horizontal]="true" formControlName="remove_all_tags"></pngx-input-switch>
|
||||
<div class="mt-n3">
|
||||
<pngx-input-tags [allowCreate]="false" [preserveHierarchy]="false" title="" formControlName="remove_tags"></pngx-input-tags>
|
||||
<pngx-input-tags [allowCreate]="false" title="" formControlName="remove_tags"></pngx-input-tags>
|
||||
</div>
|
||||
|
||||
<h6 class="form-label" i18n>Remove correspondents</h6>
|
||||
|
||||
+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, 'showSuccess')
|
||||
const toastSuccessSpy = jest.spyOn(toastService, 'showInfo')
|
||||
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, 'showSuccess')
|
||||
const toastSuccessSpy = jest.spyOn(toastService, 'showInfo')
|
||||
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.showSuccess($localize`Email sent`)
|
||||
this.toastService.showInfo($localize`Email sent`)
|
||||
},
|
||||
error: (e) => {
|
||||
this.loading.set(false)
|
||||
|
||||
+1
-3
@@ -1,7 +1,5 @@
|
||||
@if (!accordion) {
|
||||
@if (title?.length > 0) {
|
||||
<h5>{{title}}</h5>
|
||||
}
|
||||
<h5 i18n>Permissions</h5>
|
||||
<ng-container [ngTemplateOutlet]="permissionsForm"></ng-container>
|
||||
}
|
||||
@if (accordion) {
|
||||
|
||||
-3
@@ -59,9 +59,6 @@ export class PermissionsFormComponent
|
||||
@Input()
|
||||
accordion: boolean = false
|
||||
|
||||
@Input()
|
||||
title: string = $localize`Permissions`
|
||||
|
||||
form = new FormGroup({
|
||||
owner: new FormControl(null),
|
||||
set_permissions: new FormGroup({
|
||||
|
||||
@@ -222,22 +222,6 @@ describe('TagsComponent', () => {
|
||||
expect(propagated).toEqual([2, 1])
|
||||
})
|
||||
|
||||
it('should not add parents or remove children when not preserving hierarchy', () => {
|
||||
const parent: Tag = { id: 1, name: 'parent' }
|
||||
const child: Tag = { id: 2, name: 'child', parent: 1 }
|
||||
parent.children = [child]
|
||||
component.tags = [parent, child]
|
||||
component.preserveHierarchy = false
|
||||
|
||||
component.value = []
|
||||
component.addTag(2)
|
||||
expect(component.value).toEqual([2])
|
||||
|
||||
component.value = [1, 2]
|
||||
component.removeTag(1)
|
||||
expect(component.value).toEqual([2])
|
||||
})
|
||||
|
||||
it('should not duplicate parents when adding sibling nested tags', () => {
|
||||
const root: Tag = { id: 1, name: 'root' }
|
||||
const parent: Tag = { id: 2, name: 'parent', parent: 1 }
|
||||
|
||||
@@ -109,9 +109,6 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
|
||||
@Input()
|
||||
multiple: boolean = true
|
||||
|
||||
@Input()
|
||||
preserveHierarchy: boolean = true
|
||||
|
||||
@Output()
|
||||
filterDocuments = new EventEmitter<Tag[]>()
|
||||
|
||||
@@ -151,9 +148,7 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
|
||||
oldValue.splice(index, 1)
|
||||
|
||||
// remove children
|
||||
if (this.preserveHierarchy) {
|
||||
oldValue = this.removeChildren(oldValue, tag)
|
||||
}
|
||||
oldValue = this.removeChildren(oldValue, tag)
|
||||
|
||||
this.value = [...oldValue]
|
||||
this.onChange(this.value)
|
||||
@@ -172,7 +167,7 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
|
||||
}
|
||||
|
||||
public onAdd(tag: Tag) {
|
||||
if (this.preserveHierarchy && tag?.parent) {
|
||||
if (tag?.parent) {
|
||||
// add all parents recursively
|
||||
const parent = this.getTag(tag.parent)
|
||||
if (parent && !this.value.includes(parent.id)) {
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
<h3 class="d-flex align-items-center mb-2 mb-md-1" style="line-height: 1.4">
|
||||
<span class="text-truncate">{{title()}}</span>
|
||||
@if (id()) {
|
||||
<button type="button" class="badge border-0 bg-primary text-primary-text-contrast ms-3 small fs-normal" (click)="copyID()" title="Copy ID" i18n-title>
|
||||
<span class="badge bg-primary text-primary-text-contrast ms-3 small fs-normal cursor-pointer" (click)="copyID()">
|
||||
@if (copied()) {
|
||||
<i-bs width="1em" height="1em" name="clipboard-check" class="me-1"></i-bs><ng-container i18n>Copied!</ng-container>
|
||||
} @else {
|
||||
ID: {{id()}}
|
||||
}
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
@if (subTitle()) {
|
||||
<span class="page-subtitle h6 mb-0 mt-1 fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle()}}</span>
|
||||
|
||||
@@ -43,13 +43,6 @@ describe('PageHeaderComponent', () => {
|
||||
expect(titleSpy).toHaveBeenCalledWith(`Foo Bar - ${environment.appTitle}`)
|
||||
})
|
||||
|
||||
it('should set html title to just the app title until a title is available', () => {
|
||||
const titleSpy = jest.spyOn(titleService, 'setTitle')
|
||||
fixture.componentRef.setInput('title', undefined)
|
||||
fixture.detectChanges()
|
||||
expect(titleSpy).toHaveBeenCalledWith(environment.appTitle)
|
||||
})
|
||||
|
||||
it('should copy id to clipboard, reset after 3 seconds', () => {
|
||||
jest.useFakeTimers()
|
||||
fixture.componentRef.setInput('id', 42)
|
||||
|
||||
@@ -27,10 +27,7 @@ export class PageHeaderComponent {
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const title = this.title()
|
||||
this.titleService.setTitle(
|
||||
title ? `${title} - ${environment.appTitle}` : environment.appTitle
|
||||
)
|
||||
this.titleService.setTitle(`${this.title()} - ${environment.appTitle}`)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -100,7 +100,7 @@ describe('ProfileEditDialogComponent', () => {
|
||||
expect(errorSpy).toHaveBeenCalled()
|
||||
|
||||
updateSpy.mockClear()
|
||||
const infoSpy = jest.spyOn(toastService, 'showSuccess')
|
||||
const infoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
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, 'showSuccess')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
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, 'showSuccess')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
const error = new Error('failed to deactivate totp')
|
||||
deactivateSpy.mockReturnValueOnce(throwError(() => error))
|
||||
component.deactivateTotp()
|
||||
|
||||
+3
-7
@@ -204,7 +204,7 @@ export class ProfileEditDialogComponent
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.toastService.showSuccess($localize`Profile updated successfully`)
|
||||
this.toastService.showInfo($localize`Profile updated successfully`)
|
||||
if (passwordChanged) {
|
||||
this.toastService.showInfo(
|
||||
$localize`Password has been changed, you will be logged out momentarily.`
|
||||
@@ -307,9 +307,7 @@ export class ProfileEditDialogComponent
|
||||
this.recoveryCodes.set(activationResponse.recovery_codes)
|
||||
this.form.get('totp_code').enable()
|
||||
if (activationResponse.success) {
|
||||
this.toastService.showSuccess(
|
||||
$localize`TOTP activated successfully`
|
||||
)
|
||||
this.toastService.showInfo($localize`TOTP activated successfully`)
|
||||
} else {
|
||||
this.toastService.showError($localize`Error activating TOTP`)
|
||||
}
|
||||
@@ -333,9 +331,7 @@ export class ProfileEditDialogComponent
|
||||
this.isTotpEnabled.set(!success)
|
||||
this.recoveryCodes.set(null)
|
||||
if (success) {
|
||||
this.toastService.showSuccess(
|
||||
$localize`TOTP deactivated successfully`
|
||||
)
|
||||
this.toastService.showInfo($localize`TOTP deactivated successfully`)
|
||||
} else {
|
||||
this.toastService.showError($localize`Error deactivating TOTP`)
|
||||
}
|
||||
|
||||
@@ -1,33 +1,22 @@
|
||||
<ngb-toast
|
||||
[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)"
|
||||
[autohide]="autohide"
|
||||
[delay]="toast.delay"
|
||||
[class]="toast.classname"
|
||||
[class.mb-2]="true"
|
||||
(shown)="onShown(toast)"
|
||||
(hidden)="hidden.emit(toast)">
|
||||
@if (autohide) {
|
||||
<ngb-progressbar class="position-absolute 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>
|
||||
<ngb-progressbar class="position-absolute h-100 w-100 top-90 start-0 bottom-0 end-0 pe-none" type="dark" [max]="toast.delay" [value]="toast.delayRemaining"></ngb-progressbar>
|
||||
<span class="visually-hidden">{{ toast.delayRemaining / 1000 | number: '1.0-0' }} seconds</span>
|
||||
}
|
||||
<div class="d-flex align-items-top">
|
||||
@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="info-circle"></i-bs>
|
||||
}
|
||||
<div class="toast-content">
|
||||
@if (toast.error) {
|
||||
<i-bs width="0.9em" height="0.9em" name="exclamation-triangle"></i-bs>
|
||||
}
|
||||
<div>
|
||||
<p class="ms-2 mb-0 text-break">{{toast.content}}</p>
|
||||
@if (toast.error) {
|
||||
<details class="ms-2">
|
||||
@@ -59,12 +48,9 @@
|
||||
</details>
|
||||
}
|
||||
@if (toast.action) {
|
||||
<button class="btn btn-sm toast-action ms-2 mt-2" (click)="actioned.emit(toast); toast.action()">{{toast.actionName}}</button>
|
||||
<p class="mb-0 mt-2"><button class="btn btn-sm btn-outline-secondary" (click)="closed.emit(toast); toast.action()">{{toast.actionName}}</button></p>
|
||||
}
|
||||
</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,105 +2,19 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.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 {
|
||||
border-color: hsla(350, 79%, 40%, 0.4); // bg-danger
|
||||
}
|
||||
|
||||
.toast-icon {
|
||||
color: var(--pngx-toast-accent);
|
||||
::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-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 {
|
||||
height: 2px;
|
||||
background-color: transparent;
|
||||
|
||||
::ng-deep .progress-bar {
|
||||
background-color: var(--bs-secondary-color);
|
||||
opacity: .5;
|
||||
}
|
||||
background-color: var(--pngx-primary);
|
||||
opacity: .07;
|
||||
}
|
||||
@@ -1,11 +1,7 @@
|
||||
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 = {
|
||||
@@ -34,11 +30,6 @@ describe('ToastComponent', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ToastComponent, NgxBootstrapIconsModule.pick(allIcons)],
|
||||
providers: [
|
||||
DatePipe,
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
fixture = TestBed.createComponent(ToastComponent)
|
||||
@@ -55,117 +46,11 @@ describe('ToastComponent', () => {
|
||||
component.toast = toast2
|
||||
fixture.detectChanges()
|
||||
component.onShown(toast2)
|
||||
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)
|
||||
expect(component.toast.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()
|
||||
@@ -203,70 +88,4 @@ 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,26 +1,20 @@
|
||||
import { Clipboard } from '@angular/cdk/clipboard'
|
||||
import { DatePipe, DecimalPipe } from '@angular/common'
|
||||
import { 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 { Subscription, interval, takeWhile } from 'rxjs'
|
||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
import { Toast, ToastLevel } from 'src/app/services/toast.service'
|
||||
import { interval, take } from 'rxjs'
|
||||
import { Toast } from 'src/app/services/toast.service'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-toast',
|
||||
@@ -32,79 +26,32 @@ import { Toast, ToastLevel } from 'src/app/services/toast.service'
|
||||
],
|
||||
templateUrl: './toast.component.html',
|
||||
styleUrl: './toast.component.scss',
|
||||
providers: [DatePipe, CustomDatePipe],
|
||||
})
|
||||
export class ToastComponent implements OnInit {
|
||||
export class ToastComponent {
|
||||
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
|
||||
this.delayRemaining.set(toast.delay)
|
||||
const delay = toast.delay - 500 // for fade animation
|
||||
|
||||
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)
|
||||
interval(refreshInterval)
|
||||
.pipe(take(Math.round(delay / refreshInterval)))
|
||||
.subscribe((count) => {
|
||||
toast.delayRemaining = Math.max(
|
||||
0,
|
||||
delay - refreshInterval * (count + 1)
|
||||
)
|
||||
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)="removePopup(toast)" (actioned)="closeToast(toast)" (hidden)="removePopup(toast)"></pngx-toast>
|
||||
<pngx-toast [toast]="toast" [autohide]="true" (closed)="closeToast()"></pngx-toast>
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
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 { MAX_VISIBLE_TOASTS, ToastsComponent } from './toasts.component'
|
||||
import { ToastsComponent } from './toasts.component'
|
||||
|
||||
const toast = {
|
||||
content: 'Error 2 content',
|
||||
@@ -51,7 +50,7 @@ describe('ToastsComponent', () => {
|
||||
it('should close toast', () => {
|
||||
toastSubject.next(toast)
|
||||
const closeToastSpy = jest.spyOn(toastService, 'closeToast')
|
||||
component.closeToast(toast)
|
||||
component.closeToast()
|
||||
expect(component.toasts()).toEqual([])
|
||||
expect(closeToastSpy).toHaveBeenCalledWith(toast)
|
||||
})
|
||||
@@ -60,59 +59,4 @@ 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,18 +1,14 @@
|
||||
import { Component, inject, signal } from '@angular/core'
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { toSignal } 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',
|
||||
@@ -27,31 +23,18 @@ export const MAX_VISIBLE_TOASTS = 3
|
||||
export class ToastsComponent {
|
||||
toastService = inject(ToastService)
|
||||
|
||||
readonly toasts = signal<Toast[]>([])
|
||||
private readonly closedToast = new Subject<void>()
|
||||
|
||||
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
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
readonly toasts = toSignal(
|
||||
merge(
|
||||
this.toastService.showToast.pipe(map((toast) => (toast ? [toast] : []))),
|
||||
this.closedToast.pipe(map(() => [] as Toast[]))
|
||||
),
|
||||
{ initialValue: [] as Toast[] }
|
||||
)
|
||||
|
||||
closeToast(toast: Toast) {
|
||||
this.toastService.closeToast(toast)
|
||||
this.removePopup(toast)
|
||||
}
|
||||
|
||||
removePopup(toast: Toast) {
|
||||
this.toasts.update((toasts) => toasts.filter((t) => t.id !== toast.id))
|
||||
closeToast() {
|
||||
this.toastService.closeToast(this.toasts()[0])
|
||||
this.closedToast.next()
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
@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>
|
||||
@@ -1,36 +0,0 @@
|
||||
: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;
|
||||
}
|
||||
-206
@@ -1,206 +0,0 @@
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
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 { UploadDocumentsService } from 'src/app/services/upload-documents.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 expand on request', () => {
|
||||
component.toggleCollapsed()
|
||||
expect(component.collapsed()).toBe(true)
|
||||
component.expand()
|
||||
expect(component.collapsed()).toBe(false)
|
||||
})
|
||||
|
||||
it('should expand when new uploads start', () => {
|
||||
const uploadDocumentsService = TestBed.inject(UploadDocumentsService)
|
||||
jest
|
||||
.spyOn(uploadDocumentsService, 'uploadFile')
|
||||
.mockImplementation(() => {})
|
||||
websocketStatusService.newFileUpload('invoice.pdf')
|
||||
fixture.detectChanges()
|
||||
component.toggleCollapsed()
|
||||
expect(component.collapsed()).toBe(true)
|
||||
uploadDocumentsService.uploadFiles([new File(['one'], 'one.pdf')])
|
||||
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
|
||||
})
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
import { NgClass, NgTemplateOutlet } from '@angular/common'
|
||||
import { Component, effect, inject, signal } from '@angular/core'
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
|
||||
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 { UploadDocumentsService } from 'src/app/services/upload-documents.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)
|
||||
private uploadDocumentsService = inject(UploadDocumentsService)
|
||||
|
||||
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)
|
||||
})
|
||||
this.uploadDocumentsService
|
||||
.onUploadsStarted()
|
||||
.pipe(takeUntilDestroyed())
|
||||
.subscribe(() => this.expand())
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
expand() {
|
||||
this.collapsed.set(false)
|
||||
}
|
||||
|
||||
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, 'showSuccess')
|
||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||
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.showSuccess($localize`Dashboard updated`)
|
||||
this.toastService.showInfo($localize`Dashboard updated`)
|
||||
},
|
||||
error: (e) => {
|
||||
this.toastService.showError($localize`Error updating dashboard`, e)
|
||||
|
||||
+54
@@ -8,5 +8,59 @@
|
||||
</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,6 +20,48 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
+112
-4
@@ -9,11 +9,35 @@ 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 () => {
|
||||
@@ -37,6 +61,7 @@ describe('UploadFileWidgetComponent', () => {
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
websocketStatusService = TestBed.inject(WebsocketStatusService)
|
||||
uploadDocumentsService = TestBed.inject(UploadDocumentsService)
|
||||
fixture = TestBed.createComponent(UploadFileWidgetComponent)
|
||||
component = fixture.componentInstance
|
||||
@@ -60,10 +85,93 @@ describe('UploadFileWidgetComponent', () => {
|
||||
'file.pdf'
|
||||
)
|
||||
const fileInput = fixture.debugElement.query(By.css('input'))
|
||||
jest
|
||||
.spyOn(fileInput.nativeElement, 'files', 'get')
|
||||
.mockReturnValue([file] as any)
|
||||
jest.spyOn(fileInput.nativeElement, 'files', 'get').mockReturnValue({
|
||||
item: () => file,
|
||||
length: 1,
|
||||
[Symbol.iterator]: () => ({
|
||||
next: () => ({ done: false, value: file }),
|
||||
}),
|
||||
} as any)
|
||||
fileInput.nativeElement.dispatchEvent(new Event('change'))
|
||||
expect(uploadSpy).toHaveBeenCalledWith(file)
|
||||
expect(uploadSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
+119
-4
@@ -1,9 +1,23 @@
|
||||
import { Component, inject } from '@angular/core'
|
||||
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 { 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({
|
||||
@@ -13,16 +27,117 @@ 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 = ''
|
||||
const files = (event.target as HTMLInputElement).files
|
||||
for (let i = 0; i < files?.length; i++) {
|
||||
const file = files.item(i)
|
||||
file && this.uploadDocumentsService.uploadFile(file)
|
||||
}
|
||||
}
|
||||
|
||||
get slimSidebarEnabled(): boolean {
|
||||
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,25 @@
|
||||
<pngx-page-header [title]="title()" [id]="documentId()">
|
||||
@if (archiveContentRenderType === ContentRenderType.PDF && !useNativePdfViewer) {
|
||||
@if (previewNumPages()) {
|
||||
<div class="input-group input-group-sm ms-2 d-none d-md-flex">
|
||||
<div class="input-group-text" i18n>Page</div>
|
||||
<input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewNumPages()" [ngModel]="previewCurrentPage()" (ngModelChange)="previewCurrentPage.set($event)" />
|
||||
<div class="input-group-text" i18n>of {{previewNumPages()}}</div>
|
||||
</div>
|
||||
}
|
||||
<div class="input-group input-group-sm me-md-5 d-none d-md-flex">
|
||||
<button class="btn btn-outline-secondary" (click)="decreaseZoom()" i18n>-</button>
|
||||
<select class="form-select" (change)="setZoom($event.target.value)" [ngModel]="currentZoom">
|
||||
@for (setting of zoomSettings; track setting) {
|
||||
<option [value]="setting">
|
||||
{{ getZoomSettingTitle(setting) }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
<button class="btn btn-outline-secondary" (click)="increaseZoom()" i18n>+</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<button type="button" class="btn btn-sm btn-outline-danger me-md-4" (click)="delete()" [disabled]="!userIsOwner" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }" aria-label="Delete" i18n-aria-label>
|
||||
<i-bs width="1.2em" height="1.2em" name="trash"></i-bs><span class="d-none d-lg-inline ps-1" i18n>Delete</span>
|
||||
</button>
|
||||
@@ -13,7 +34,7 @@
|
||||
(versionsUpdated)="onVersionsUpdated($event)"
|
||||
/>
|
||||
|
||||
<div class="btn-group ms-4">
|
||||
<div class="btn-group">
|
||||
<button (click)="download()" class="btn btn-sm btn-outline-primary" [disabled]="downloading()" aria-label="Download" i18n-aria-label>
|
||||
@if (downloading()) {
|
||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||
@@ -73,7 +94,7 @@
|
||||
<button class="btn btn-sm btn-outline-primary" id="sendDropdown" ngbDropdownToggle aria-label="Send" i18n-aria-label>
|
||||
<i-bs name="send"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Send</ng-container></div>
|
||||
</button>
|
||||
<div ngbDropdownMenu aria-labelledby="sendDropdown" class="shadow">
|
||||
<div ngbDropdownMenu aria-labelledby="actionsDropdown" class="shadow">
|
||||
<button ngbDropdownItem (click)="openShareLinks()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.ShareLink }">
|
||||
<i-bs name="link" class="me-1"></i-bs><span i18n>Share Links</span>
|
||||
</button>
|
||||
@@ -92,8 +113,8 @@
|
||||
|
||||
<form [formGroup]='documentForm' (ngSubmit)="save()">
|
||||
|
||||
<div class="btn-toolbar mb-1 pb-3 gap-2 row-gap-2 border-bottom">
|
||||
<div class="btn-group">
|
||||
<div class="btn-toolbar justify-content-end mb-1 pb-3 gap-2 row-gap-2 border-bottom">
|
||||
<div class="btn-group me-auto">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Close" (click)="close()">
|
||||
<i-bs width="1.2em" height="1.2em" name="x"></i-bs>
|
||||
</button>
|
||||
@@ -106,30 +127,43 @@
|
||||
</div>
|
||||
|
||||
<ng-container *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
|
||||
<div class="btn-group ms-3">
|
||||
<pngx-suggestions-dropdown *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }"
|
||||
[disabled]="!userCanEdit || suggestionsLoading()"
|
||||
[loading]="suggestionsLoading()"
|
||||
[suggestions]="suggestions()"
|
||||
[aiEnabled]="aiEnabled"
|
||||
[source]="suggestionSource"
|
||||
[defaultSource]="defaultSuggestionSource"
|
||||
[fetchedSources]="fetchedSuggestionSources()"
|
||||
[appliedTags]="documentForm.value.tags"
|
||||
[appliedCorrespondent]="documentForm.value.correspondent"
|
||||
[appliedDocumentType]="documentForm.value.document_type"
|
||||
[appliedStoragePath]="documentForm.value.storage_path"
|
||||
[appliedTitle]="documentForm.value.title"
|
||||
[appliedCreated]="documentForm.value.created"
|
||||
(getSuggestions)="getSuggestions($event)"
|
||||
(sourceChange)="suggestionSourceOverride.set($event)"
|
||||
(addTag)="createTag($event)"
|
||||
(addDocumentType)="createDocumentType($event)"
|
||||
(addCorrespondent)="createCorrespondent($event)">
|
||||
</pngx-suggestions-dropdown>
|
||||
<div class="d-flex gap-2">
|
||||
<div class="btn-group">
|
||||
<pngx-suggestions-dropdown *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }"
|
||||
[disabled]="!userCanEdit || suggestionsLoading()"
|
||||
[loading]="suggestionsLoading()"
|
||||
[suggestions]="suggestions()"
|
||||
[aiEnabled]="aiEnabled"
|
||||
[source]="suggestionSource"
|
||||
[defaultSource]="defaultSuggestionSource"
|
||||
[fetchedSources]="fetchedSuggestionSources()"
|
||||
[appliedTags]="documentForm.value.tags"
|
||||
[appliedCorrespondent]="documentForm.value.correspondent"
|
||||
[appliedDocumentType]="documentForm.value.document_type"
|
||||
[appliedStoragePath]="documentForm.value.storage_path"
|
||||
[appliedTitle]="documentForm.value.title"
|
||||
[appliedCreated]="documentForm.value.created"
|
||||
(getSuggestions)="getSuggestions($event)"
|
||||
(sourceChange)="suggestionSourceOverride.set($event)"
|
||||
(addTag)="createTag($event)"
|
||||
(addDocumentType)="createDocumentType($event)"
|
||||
(addCorrespondent)="createCorrespondent($event)">
|
||||
</pngx-suggestions-dropdown>
|
||||
</div>
|
||||
|
||||
<div class="btn-group">
|
||||
<pngx-custom-fields-dropdown
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }"
|
||||
[documentId]="documentId()"
|
||||
[disabled]="!userCanEdit"
|
||||
[existingFields]="document()?.custom_fields"
|
||||
(created)="refreshCustomFields()"
|
||||
(added)="addField($event)">
|
||||
</pngx-custom-fields-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ms-auto">
|
||||
<div class="ps-3">
|
||||
<ng-container *ngTemplateOutlet="saveButtons"></ng-container>
|
||||
</div>
|
||||
</ng-container>
|
||||
@@ -256,20 +290,8 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="d-flex border-top pt-3">
|
||||
<ng-container *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
|
||||
<pngx-custom-fields-dropdown
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }"
|
||||
[documentId]="documentId()"
|
||||
[disabled]="!userCanEdit"
|
||||
[existingFields]="document()?.custom_fields"
|
||||
(created)="refreshCustomFields()"
|
||||
(added)="addField($event)">
|
||||
</pngx-custom-fields-dropdown>
|
||||
</ng-container>
|
||||
<div class="ms-auto">
|
||||
<ng-container *ngTemplateOutlet="saveButtons"></ng-container>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end border-top pt-3">
|
||||
<ng-container *ngTemplateOutlet="saveButtons"></ng-container>
|
||||
</div>
|
||||
</ng-template>
|
||||
</li>
|
||||
@@ -306,9 +328,9 @@
|
||||
<td i18n>Original filename</td>
|
||||
<td>{{metadata()?.original_filename}}</td>
|
||||
</tr>
|
||||
<tr class="align-middle">
|
||||
<tr>
|
||||
<td i18n>Original SHA256 checksum</td>
|
||||
<td><span class="badge bg-light font-monospace text-wrap text-break text-start">{{metadata()?.original_checksum}}</span></td>
|
||||
<td>{{metadata()?.original_checksum}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td i18n>Original file size</td>
|
||||
@@ -319,9 +341,9 @@
|
||||
<td>{{metadata()?.original_mime_type}}</td>
|
||||
</tr>
|
||||
@if (metadata()?.has_archive_version) {
|
||||
<tr class="align-middle">
|
||||
<tr>
|
||||
<td i18n>Archive SHA256 checksum</td>
|
||||
<td><span class="badge bg-light font-monospace text-wrap text-break text-start">{{metadata()?.archive_checksum}}</span></td>
|
||||
<td>{{metadata()?.archive_checksum}}</td>
|
||||
</tr>
|
||||
}
|
||||
@if (metadata()?.has_archive_version) {
|
||||
@@ -386,7 +408,7 @@
|
||||
<a ngbNavLink i18n>Permissions</a>
|
||||
<ng-template ngbNavContent>
|
||||
<div class="mb-3">
|
||||
<pngx-permissions-form title="" [users]="users()" formControlName="permissions_form"></pngx-permissions-form>
|
||||
<pngx-permissions-form [users]="users()" formControlName="permissions_form"></pngx-permissions-form>
|
||||
</div>
|
||||
</ng-template>
|
||||
</li>
|
||||
@@ -477,29 +499,6 @@
|
||||
(loadError)="onError($event)"
|
||||
(afterLoadComplete)="pdfPreviewLoaded($event)">
|
||||
</pngx-pdf-viewer>
|
||||
<div class="position-absolute bottom-0 start-50 translate-middle-x z-1 mb-3 d-flex gap-2 p-1 bg-body border rounded shadow">
|
||||
@if (previewNumPages()) {
|
||||
<div class="input-group input-group-sm w-auto flex-nowrap">
|
||||
<input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewNumPages()" [ngModelOptions]="{standalone: true}" [ngModel]="previewCurrentPage()" (ngModelChange)="previewCurrentPage.set($event)" aria-label="Page" i18n-aria-label />
|
||||
<div class="input-group-text" i18n>of {{previewNumPages()}}</div>
|
||||
</div>
|
||||
}
|
||||
<div class="input-group input-group-sm w-auto flex-nowrap">
|
||||
<button class="btn btn-outline-secondary" (click)="decreaseZoom()" aria-label="Zoom out" i18n-aria-label>
|
||||
<i-bs width="1em" height="1em" name="dash"></i-bs>
|
||||
</button>
|
||||
<select class="form-select" (change)="setZoom($event.target.value)" [ngModelOptions]="{standalone: true}" [ngModel]="currentZoom" aria-label="Zoom" i18n-aria-label>
|
||||
@for (setting of zoomSettings; track setting) {
|
||||
<option [value]="setting">
|
||||
{{ getZoomSettingTitle(setting) }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
<button class="btn btn-outline-secondary" (click)="increaseZoom()" aria-label="Zoom in" i18n-aria-label>
|
||||
<i-bs width="1em" height="1em" name="plus"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<object [data]="previewUrl() | safeUrl" class="preview-sticky" width="100%"></object>
|
||||
|
||||
@@ -60,7 +60,6 @@ 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'
|
||||
@@ -138,7 +137,6 @@ describe('DocumentDetailComponent', () => {
|
||||
let openDocumentsService: OpenDocumentsService
|
||||
let modalService: NgbModal
|
||||
let toastService: ToastService
|
||||
let trashService: TrashService
|
||||
let documentListViewService: DocumentListViewService
|
||||
let settingsService: SettingsService
|
||||
let customFieldsService: CustomFieldsService
|
||||
@@ -287,7 +285,6 @@ 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 })
|
||||
@@ -778,7 +775,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, 'showSuccess')
|
||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||
patchSpy.mockImplementation((o) => of(doc))
|
||||
component.save(true)
|
||||
expect(patchSpy).toHaveBeenCalled()
|
||||
@@ -793,7 +790,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, 'showSuccess')
|
||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||
patchSpy.mockImplementation((o) => of(doc))
|
||||
component.save()
|
||||
expect(patchSpy).toHaveBeenCalled()
|
||||
@@ -839,7 +836,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, 'showSuccess')
|
||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||
patchSpy.mockImplementation(() =>
|
||||
throwError(() => new Error('failed to save'))
|
||||
)
|
||||
@@ -960,16 +957,12 @@ 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,7 +95,6 @@ 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'
|
||||
@@ -222,7 +221,6 @@ 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)
|
||||
@@ -1351,7 +1349,7 @@ export class DocumentDetailComponent
|
||||
this.store.next(newValues)
|
||||
this.openDocumentService.setDirty(this.document(), false)
|
||||
this.openDocumentService.save()
|
||||
this.toastService.showSuccess(
|
||||
this.toastService.showInfo(
|
||||
$localize`Document "${newValues.title}" saved successfully.`
|
||||
)
|
||||
this.networkActive.set(false)
|
||||
@@ -1379,7 +1377,7 @@ export class DocumentDetailComponent
|
||||
if (!canEdit) {
|
||||
// document was 'given away'
|
||||
this.openDocumentService.setDirty(this.document(), false)
|
||||
this.toastService.showSuccess(
|
||||
this.toastService.showInfo(
|
||||
$localize`Document "${this.document().title}" saved successfully.`
|
||||
)
|
||||
this.close()
|
||||
@@ -1492,10 +1490,6 @@ 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()
|
||||
},
|
||||
|
||||
+2
-5
@@ -33,9 +33,7 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
| 'updateVersionLabel'
|
||||
>
|
||||
>
|
||||
let toastService: jest.Mocked<
|
||||
Pick<ToastService, 'showError' | 'showInfo' | 'showSuccess'>
|
||||
>
|
||||
let toastService: jest.Mocked<Pick<ToastService, 'showError' | 'showInfo'>>
|
||||
let finished$: Subject<{ taskId: string }>
|
||||
let failed$: Subject<{ taskId: string; message?: string }>
|
||||
let modalService: jest.Mocked<Pick<NgbModal, 'open'>>
|
||||
@@ -57,7 +55,6 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
toastService = {
|
||||
showError: jest.fn(),
|
||||
showInfo: jest.fn(),
|
||||
showSuccess: jest.fn(),
|
||||
}
|
||||
permissionsService = {
|
||||
currentUserCan: jest.fn().mockReturnValue(true),
|
||||
@@ -419,6 +416,6 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
expect(selectedEmitSpy).toHaveBeenCalledWith(2)
|
||||
expect(component.newVersionLabel).toEqual('')
|
||||
expect(modal.close).toHaveBeenCalled()
|
||||
expect(toastService.showSuccess).toHaveBeenCalled()
|
||||
expect(toastService.showInfo).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
+1
-1
@@ -320,7 +320,7 @@ export class DocumentVersionDropdownComponent
|
||||
}
|
||||
this.newVersionLabel = ''
|
||||
modal.close()
|
||||
this.toastService.showSuccess(
|
||||
this.toastService.showInfo(
|
||||
$localize`Existing document added as a version.`
|
||||
)
|
||||
},
|
||||
|
||||
@@ -39,7 +39,6 @@ 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'
|
||||
@@ -75,7 +74,6 @@ describe('BulkEditorComponent', () => {
|
||||
let documentListViewService: DocumentListViewService
|
||||
let documentService: DocumentService
|
||||
let toastService: ToastService
|
||||
let trashService: TrashService
|
||||
let modalService: NgbModal
|
||||
let tagService: TagService
|
||||
let correspondentsService: CorrespondentService
|
||||
@@ -187,7 +185,6 @@ 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)
|
||||
@@ -1167,14 +1164,10 @@ 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
|
||||
@@ -1184,19 +1177,10 @@ 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', () => {
|
||||
@@ -1432,7 +1416,7 @@ describe('BulkEditorComponent', () => {
|
||||
const mergeAsVersionsSpy = jest
|
||||
.spyOn(documentService, 'mergeDocumentsAsVersions')
|
||||
.mockReturnValue(of(true))
|
||||
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
fixture.detectChanges()
|
||||
|
||||
component.mergeSelectedAsVersions()
|
||||
@@ -1448,9 +1432,7 @@ describe('BulkEditorComponent', () => {
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||
)
|
||||
expect(documentListViewService.selected.size).toEqual(0)
|
||||
expect(toastSuccessSpy).toHaveBeenCalledWith(
|
||||
'Documents merged as versions.'
|
||||
)
|
||||
expect(toastInfoSpy).toHaveBeenCalledWith('Documents merged as versions.')
|
||||
})
|
||||
|
||||
it('should not report success when merging documents as versions fails', () => {
|
||||
@@ -1476,7 +1458,7 @@ describe('BulkEditorComponent', () => {
|
||||
jest
|
||||
.spyOn(documentService, 'mergeDocumentsAsVersions')
|
||||
.mockReturnValue(throwError(() => new Error('failed')))
|
||||
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
fixture.detectChanges()
|
||||
|
||||
@@ -1485,7 +1467,7 @@ describe('BulkEditorComponent', () => {
|
||||
modal.componentInstance.confirm()
|
||||
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
expect(toastSuccessSpy).not.toHaveBeenCalled()
|
||||
expect(toastInfoSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should support bulk download with archive, originals or both and file formatting', () => {
|
||||
@@ -1864,7 +1846,7 @@ describe('BulkEditorComponent', () => {
|
||||
.spyOn(documentListViewService, 'selected', 'get')
|
||||
.mockReturnValue(new Set([3, 4]))
|
||||
fixture.detectChanges()
|
||||
const toastServiceShowInfoSpy = jest.spyOn(toastService, 'showSuccess')
|
||||
const toastServiceShowInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
const toastServiceShowErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const listReloadSpy = jest.spyOn(documentListViewService, 'reload')
|
||||
|
||||
|
||||
@@ -20,15 +20,7 @@ import {
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { saveAs } from 'file-saver'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import {
|
||||
first,
|
||||
map,
|
||||
Observable,
|
||||
Subject,
|
||||
switchMap,
|
||||
takeUntil,
|
||||
tap,
|
||||
} from 'rxjs'
|
||||
import { first, map, Observable, Subject, switchMap, takeUntil } 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'
|
||||
@@ -57,7 +49,6 @@ 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'
|
||||
@@ -108,7 +99,6 @@ 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)
|
||||
@@ -316,7 +306,7 @@ export class BulkEditorComponent
|
||||
next: () => {
|
||||
this.handleOperationSuccess(modal, options.clearSelection ?? false)
|
||||
if (options.successMessage) {
|
||||
this.toastService.showSuccess(options.successMessage)
|
||||
this.toastService.showInfo(options.successMessage)
|
||||
}
|
||||
},
|
||||
error: (error) => this.handleOperationError(modal, error),
|
||||
@@ -894,33 +884,19 @@ export class BulkEditorComponent
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled.set(false)
|
||||
this.deleteSelected(modal)
|
||||
this.executeDocumentAction(
|
||||
modal,
|
||||
this.documentService.deleteDocuments(this.getSelectionQuery())
|
||||
)
|
||||
})
|
||||
} else {
|
||||
this.deleteSelected(null)
|
||||
this.executeDocumentAction(
|
||||
null,
|
||||
this.documentService.deleteDocuments(this.getSelectionQuery())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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 =
|
||||
@@ -1089,7 +1065,7 @@ export class BulkEditorComponent
|
||||
dialog.selection = this.getSelectionQuery()
|
||||
dialog.selectionCount = this.getSelectionSize()
|
||||
dialog.succeeded.subscribe((result) => {
|
||||
this.toastService.showSuccess($localize`Custom fields updated.`)
|
||||
this.toastService.showInfo($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, 'showSuccess')
|
||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||
|
||||
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, 'showSuccess')
|
||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||
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.showSuccess(
|
||||
this.toastService.showInfo(
|
||||
$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.showSuccess(
|
||||
this.toastService.showInfo(
|
||||
$localize`View "${savedView.name}" created successfully.`
|
||||
)
|
||||
},
|
||||
|
||||
@@ -119,7 +119,7 @@ describe('FileDropComponent', () => {
|
||||
}
|
||||
component.onDrop(dragEvent as DragEvent)
|
||||
advanceTimers(3000)
|
||||
expect(toastSpy).not.toHaveBeenCalled()
|
||||
expect(toastSpy).toHaveBeenCalled()
|
||||
expect(uploadSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -159,7 +159,7 @@ describe('FileDropComponent', () => {
|
||||
component.onDrop(dragEvent as DragEvent)
|
||||
await flushPromises()
|
||||
advanceTimers(3000)
|
||||
expect(toastSpy).not.toHaveBeenCalled()
|
||||
expect(toastSpy).toHaveBeenCalled()
|
||||
expect(uploadSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -227,7 +227,7 @@ describe('FileDropComponent', () => {
|
||||
}
|
||||
component.onDrop(dragEvent as DragEvent)
|
||||
advanceTimers(3000)
|
||||
expect(toastSpy).not.toHaveBeenCalled()
|
||||
expect(toastSpy).toHaveBeenCalled()
|
||||
expect(uploadSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -134,7 +134,8 @@ export class FileDropComponent {
|
||||
Promise.all(promises)
|
||||
.then((results) => {
|
||||
files.push(...[].concat(...results))
|
||||
this.uploadDocumentsService.uploadFiles(files)
|
||||
this.toastService.showInfo($localize`Initiating upload...`, 3000)
|
||||
files.forEach((file) => this.uploadDocumentsService.uploadFile(file))
|
||||
})
|
||||
.catch((e) => {
|
||||
this.toastService.showError(
|
||||
@@ -142,7 +143,8 @@ export class FileDropComponent {
|
||||
)
|
||||
})
|
||||
} else if (files.length) {
|
||||
this.uploadDocumentsService.uploadFiles(files)
|
||||
this.toastService.showInfo($localize`Initiating upload...`, 3000)
|
||||
files.forEach((file) => this.uploadDocumentsService.uploadFile(file))
|
||||
}
|
||||
|
||||
this.onDragLeave(event, true)
|
||||
|
||||
+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, 'showSuccess')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
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, 'showSuccess')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
const reloadSpy = jest.spyOn(component, 'reload')
|
||||
|
||||
const editButton = fixture.debugElement
|
||||
|
||||
+2
-6
@@ -82,9 +82,7 @@ export class CustomFieldsComponent
|
||||
modal.componentInstance.succeeded
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((newField) => {
|
||||
this.toastService.showSuccess(
|
||||
$localize`Saved field "${newField.name}".`
|
||||
)
|
||||
this.toastService.showInfo($localize`Saved field "${newField.name}".`)
|
||||
this.customFieldsService.clearCache()
|
||||
this.settingsService.initializeDisplayFields()
|
||||
this.documentService.reload()
|
||||
@@ -111,9 +109,7 @@ export class CustomFieldsComponent
|
||||
this.customFieldsService.delete(field).subscribe({
|
||||
next: () => {
|
||||
modal.close()
|
||||
this.toastService.showSuccess(
|
||||
$localize`Deleted field "${field.name}"`
|
||||
)
|
||||
this.toastService.showInfo($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, 'showSuccess')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
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, 'showSuccess')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
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, 'showSuccess')
|
||||
const successToastSpy = jest.spyOn(toastService, 'showInfo')
|
||||
bulkEditPermsSpy.mockReturnValueOnce(of('OK'))
|
||||
modal.componentInstance.confirmClicked.emit({
|
||||
permissions: {},
|
||||
@@ -452,7 +452,7 @@ describe('ManagementListComponent', () => {
|
||||
)
|
||||
expect(errorToastSpy).toHaveBeenCalled()
|
||||
|
||||
const successToastSpy = jest.spyOn(toastService, 'showSuccess')
|
||||
const successToastSpy = jest.spyOn(toastService, 'showInfo')
|
||||
bulkEditSpy.mockReturnValueOnce(of('OK'))
|
||||
modal.componentInstance.confirmClicked.emit(null)
|
||||
expect(bulkEditSpy).toHaveBeenCalled()
|
||||
|
||||
+4
-6
@@ -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.showSuccess(
|
||||
this.toastService.showInfo(
|
||||
$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.showSuccess(
|
||||
this.toastService.showInfo(
|
||||
$localize`Successfully updated ${this.typeName} "${object.name}".`
|
||||
)
|
||||
})
|
||||
@@ -428,7 +428,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
||||
.subscribe({
|
||||
next: () => {
|
||||
modal.close()
|
||||
this.toastService.showSuccess(
|
||||
this.toastService.showInfo(
|
||||
$localize`Permissions updated successfully`
|
||||
)
|
||||
this.reloadData()
|
||||
@@ -468,9 +468,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
||||
.subscribe({
|
||||
next: () => {
|
||||
modal.close()
|
||||
this.toastService.showSuccess(
|
||||
$localize`Objects deleted successfully`
|
||||
)
|
||||
this.toastService.showInfo($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, 'showSuccess')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
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, 'showSuccess')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
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, 'showSuccess')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
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, 'showSuccess')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
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, 'showSuccess')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
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, 'showSuccess')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
// 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, 'showSuccess')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
completeSetup()
|
||||
expect(toastInfoSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -135,9 +135,7 @@ export class MailComponent
|
||||
if (params.get('oauth_success')) {
|
||||
const success = params.get('oauth_success') === '1'
|
||||
if (success) {
|
||||
this.toastService.showSuccess(
|
||||
$localize`OAuth2 authentication success`
|
||||
)
|
||||
this.toastService.showInfo($localize`OAuth2 authentication success`)
|
||||
this.oAuthAccountId = parseInt(params.get('account_id'))
|
||||
if (this.mailAccounts().length > 0) {
|
||||
this.editMailAccount(
|
||||
@@ -171,7 +169,7 @@ export class MailComponent
|
||||
modal.componentInstance.succeeded
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((newMailAccount) => {
|
||||
this.toastService.showSuccess(
|
||||
this.toastService.showInfo(
|
||||
$localize`Saved account "${newMailAccount.name}".`
|
||||
)
|
||||
this.mailAccountService.clearCache()
|
||||
@@ -202,7 +200,7 @@ export class MailComponent
|
||||
this.mailAccountService.delete(account).subscribe({
|
||||
next: () => {
|
||||
modal.close()
|
||||
this.toastService.showSuccess(
|
||||
this.toastService.showInfo(
|
||||
$localize`Deleted mail account "${account.name}"`
|
||||
)
|
||||
this.mailAccountService.clearCache()
|
||||
@@ -250,9 +248,7 @@ export class MailComponent
|
||||
modal.componentInstance.succeeded
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((newMailRule) => {
|
||||
this.toastService.showSuccess(
|
||||
$localize`Saved rule "${newMailRule.name}".`
|
||||
)
|
||||
this.toastService.showInfo($localize`Saved rule "${newMailRule.name}".`)
|
||||
this.mailRuleService.clearCache()
|
||||
this.mailRuleService
|
||||
.listAll(null, null, { full_perms: true })
|
||||
@@ -277,7 +273,7 @@ export class MailComponent
|
||||
onMailRuleEnableToggled(rule: MailRule) {
|
||||
this.mailRuleService.patch(rule).subscribe({
|
||||
next: () => {
|
||||
this.toastService.showSuccess(
|
||||
this.toastService.showInfo(
|
||||
rule.enabled
|
||||
? $localize`Rule "${rule.name}" enabled.`
|
||||
: $localize`Rule "${rule.name}" disabled.`
|
||||
@@ -306,7 +302,7 @@ export class MailComponent
|
||||
this.mailRuleService.delete(rule).subscribe({
|
||||
next: () => {
|
||||
modal.close()
|
||||
this.toastService.showSuccess(
|
||||
this.toastService.showInfo(
|
||||
$localize`Deleted mail rule "${rule.name}"`
|
||||
)
|
||||
this.mailRuleService.clearCache()
|
||||
@@ -342,7 +338,7 @@ export class MailComponent
|
||||
object['set_permissions'] = permissions['set_permissions']
|
||||
service.patch(object).subscribe({
|
||||
next: () => {
|
||||
this.toastService.showSuccess($localize`Permissions updated`)
|
||||
this.toastService.showInfo($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, 'showSuccess')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
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.showSuccess($localize`Processed mail(s) deleted`)
|
||||
this.toastService.showInfo($localize`Processed mail(s) deleted`)
|
||||
this.loadProcessedMails()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -227,7 +227,7 @@ describe('SavedViewsComponent', () => {
|
||||
})
|
||||
|
||||
it('should support delete saved view', () => {
|
||||
const toastSpy = jest.spyOn(toastService, 'showSuccess')
|
||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||
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.showSuccess(
|
||||
this.toastService.showInfo(
|
||||
$localize`Saved view "${savedView.name}" deleted.`
|
||||
)
|
||||
this.savedViewService.clearCache()
|
||||
@@ -262,7 +262,7 @@ export class SavedViewsComponent
|
||||
|
||||
saveOperation.subscribe({
|
||||
next: () => {
|
||||
this.toastService.showSuccess($localize`Views saved successfully.`)
|
||||
this.toastService.showInfo($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.showSuccess($localize`Permissions updated`)
|
||||
this.toastService.showInfo($localize`Permissions updated`)
|
||||
modal.close()
|
||||
this.reloadViews()
|
||||
},
|
||||
|
||||
+1
-2
@@ -22,7 +22,6 @@ class MockShareLinkBundleService {
|
||||
|
||||
class MockToastService {
|
||||
showInfo = jest.fn()
|
||||
showSuccess = jest.fn()
|
||||
showError = jest.fn()
|
||||
}
|
||||
|
||||
@@ -211,7 +210,7 @@ describe('ShareLinkBundleListComponent', () => {
|
||||
component.delete(sampleBundle())
|
||||
|
||||
expect(service.delete).toHaveBeenCalled()
|
||||
expect(toastService.showSuccess).toHaveBeenCalledWith(
|
||||
expect(toastService.showInfo).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.showSuccess($localize`Share link bundle deleted.`)
|
||||
this.toastService.showInfo($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, 'showSuccess' | 'showError'>>
|
||||
let toastService: jest.Mocked<Pick<ToastService, 'showInfo' | 'showError'>>
|
||||
|
||||
const link = {
|
||||
id: 1,
|
||||
@@ -33,7 +33,7 @@ describe('ShareLinkListComponent', () => {
|
||||
delete: jest.fn().mockReturnValue(of(true)),
|
||||
}
|
||||
toastService = {
|
||||
showSuccess: jest.fn(),
|
||||
showInfo: 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.showSuccess).not.toHaveBeenCalled()
|
||||
expect(toastService.showInfo).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.showSuccess).toHaveBeenCalled()
|
||||
expect(toastService.showInfo).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.showSuccess($localize`Share link deleted.`)
|
||||
this.toastService.showInfo($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, 'showSuccess')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
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, 'showSuccess')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
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, 'showSuccess')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
// 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.showSuccess(
|
||||
this.toastService.showInfo(
|
||||
$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.showSuccess(
|
||||
this.toastService.showInfo(
|
||||
$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.showSuccess(
|
||||
this.toastService.showInfo(
|
||||
workflow.enabled
|
||||
? $localize`Enabled workflow "${workflow.name}"`
|
||||
: $localize`Disabled workflow "${workflow.name}"`
|
||||
|
||||
@@ -62,10 +62,14 @@ 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',
|
||||
@@ -193,6 +197,11 @@ 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',
|
||||
@@ -203,6 +212,11 @@ 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',
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { MarkdownPipe } from './markdown.pipe'
|
||||
|
||||
describe('MarkdownPipe', () => {
|
||||
const pipe = new MarkdownPipe()
|
||||
|
||||
it('should return empty string for empty input', () => {
|
||||
expect(pipe.transform(null)).toEqual('')
|
||||
expect(pipe.transform(undefined)).toEqual('')
|
||||
expect(pipe.transform('')).toEqual('')
|
||||
})
|
||||
|
||||
it('should render basic markdown', () => {
|
||||
const html = pipe.transform(
|
||||
'**bold** _em_ `code`\n\n- one\n- two\n\n| a | b |\n|---|---|\n| 1 | 2 |'
|
||||
)
|
||||
expect(html).toContain('<strong>bold</strong>')
|
||||
expect(html).toContain('<em>em</em>')
|
||||
expect(html).toContain('<code>code</code>')
|
||||
expect(html).toContain('<li>one</li>')
|
||||
expect(html).toContain('<table>')
|
||||
})
|
||||
|
||||
it('should escape raw html', () => {
|
||||
const html = pipe.transform(
|
||||
'hi <img src=x onerror="alert(1)"> <b>x</b>\n\n<script>alert(1)</script>'
|
||||
)
|
||||
expect(html).not.toContain('<img')
|
||||
expect(html).not.toContain('<script')
|
||||
expect(html).not.toContain('<b>')
|
||||
expect(html).toContain('<script>')
|
||||
})
|
||||
|
||||
it('should not render images', () => {
|
||||
const html = pipe.transform(
|
||||
''
|
||||
)
|
||||
expect(html).not.toContain('<img')
|
||||
expect(html).not.toContain('otherdomain.example')
|
||||
expect(html).toContain('secret')
|
||||
})
|
||||
|
||||
it('should render safe links with target and rel', () => {
|
||||
const html = pipe.transform('[docs](https://docs.paperless-ngx.com "Docs")')
|
||||
expect(html).toContain(
|
||||
'<a target="_blank" rel="noopener noreferrer nofollow" href="https://docs.paperless-ngx.com" title="Docs">docs</a>'
|
||||
)
|
||||
expect(pipe.transform('[mail](mailto:a@b.c)')).toContain(
|
||||
'href="mailto:a@b.c"'
|
||||
)
|
||||
})
|
||||
|
||||
it('should drop unsafe or relative links but keep their text', () => {
|
||||
for (const href of [
|
||||
'javascript:alert(1)',
|
||||
'JaVaScRiPt:alert(1)',
|
||||
'data:text/html,<script>alert(1)</script>',
|
||||
'vbscript:msgbox',
|
||||
'/api/documents/',
|
||||
]) {
|
||||
const html = pipe.transform(`[click](${href})`)
|
||||
expect(html).not.toContain('<a')
|
||||
expect(html).toContain('click')
|
||||
}
|
||||
})
|
||||
|
||||
it('should show the actual hostname beside web links with misleading labels', () => {
|
||||
for (const protocol of ['http', 'https']) {
|
||||
const html = pipe.transform(
|
||||
`[docs.paperless-ngx.com](${protocol}://docs.paperless-ngx.com@otherdomain.example/path?secret=1)`
|
||||
)
|
||||
expect(html).toContain(
|
||||
'</a> <span class="text-muted small">(otherdomain.example)</span>'
|
||||
)
|
||||
expect(html).not.toContain('(docs.paperless-ngx.com)')
|
||||
}
|
||||
})
|
||||
|
||||
it('should omit the hostname label for mailto links', () => {
|
||||
expect(pipe.transform('[mail](mailto:a@b.c)')).toBe(
|
||||
'<p><a target="_blank" rel="noopener noreferrer nofollow" href="mailto:a@b.c">mail</a></p>\n'
|
||||
)
|
||||
})
|
||||
|
||||
it('should escape link titles', () => {
|
||||
const html = pipe.transform(
|
||||
'[x](https://a.example "a\\" onmouseover=\\"1")'
|
||||
)
|
||||
expect(html).not.toMatch(/"\s*onmouseover=/)
|
||||
})
|
||||
})
|
||||
@@ -1,52 +0,0 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import { Marked, Renderer, Tokens } from 'marked'
|
||||
|
||||
const ALLOWED_LINK_PROTOCOLS = new Set(['http:', 'https:', 'mailto:'])
|
||||
|
||||
function allowedUrl(href: string): URL | null {
|
||||
try {
|
||||
const url = new URL(href)
|
||||
return ALLOWED_LINK_PROTOCOLS.has(url.protocol) ? url : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Treat chat content as untrusted: no raw HTML, no images, and only
|
||||
// absolute links. All escaping is done by marked's default renderer, and
|
||||
// Angular's sanitizer runs on top of this as well.
|
||||
const renderer: Partial<Renderer> = {
|
||||
html(this: Renderer, { raw, text }: Tokens.HTML | Tokens.Tag): string {
|
||||
return this.text({ type: 'text', raw, text })
|
||||
},
|
||||
image(this: Renderer, { raw, text }: Tokens.Image): string {
|
||||
return this.text({ type: 'text', raw, text })
|
||||
},
|
||||
link(this: Renderer, token: Tokens.Link): string {
|
||||
const url = allowedUrl(token.href)
|
||||
if (!url) return this.parser.parseInline(token.tokens)
|
||||
const anchor = Renderer.prototype.link
|
||||
.call(this, token)
|
||||
.replace('<a ', '<a target="_blank" rel="noopener noreferrer nofollow" ')
|
||||
if (!url.hostname) return anchor
|
||||
const hostname = this.text({ type: 'text', raw: '', text: url.hostname })
|
||||
return `${anchor} <span class="text-muted small">(${hostname})</span>`
|
||||
},
|
||||
}
|
||||
|
||||
const markdown = new Marked({
|
||||
async: false,
|
||||
gfm: true,
|
||||
breaks: true,
|
||||
renderer,
|
||||
})
|
||||
|
||||
@Pipe({
|
||||
name: 'markdown',
|
||||
})
|
||||
export class MarkdownPipe implements PipeTransform {
|
||||
transform(value: string | null | undefined): string {
|
||||
if (!value) return ''
|
||||
return markdown.parse(value) as string
|
||||
}
|
||||
}
|
||||
@@ -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_SUCCESS)
|
||||
settingsService.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT)
|
||||
).toBeTruthy()
|
||||
expect(settingsService.get(SETTINGS_KEYS.DOCUMENT_LIST_SIZE)).toEqual(50)
|
||||
expect(settingsService.get(SETTINGS_KEYS.THEME_COLOR)).toEqual('#9fbf2f')
|
||||
@@ -198,14 +198,17 @@ describe('SettingsService', () => {
|
||||
|
||||
settingsService.setLanguage('de-de')
|
||||
settingsService.set(SETTINGS_KEYS.DARK_MODE_ENABLED, true)
|
||||
settingsService.set(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS, false)
|
||||
settingsService.set(
|
||||
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT,
|
||||
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_SUCCESS)
|
||||
settingsService.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT)
|
||||
).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.showSuccess(successMessage)
|
||||
this.toastService.showInfo(successMessage)
|
||||
},
|
||||
error: (e) => {
|
||||
this.toastService.showError(errorMessage)
|
||||
@@ -741,10 +741,9 @@ export class SettingsService {
|
||||
this.storeSettings()
|
||||
.pipe(first())
|
||||
.subscribe(() => {
|
||||
this.toastService.show({
|
||||
content: $localize`You can restart the tour from the settings page.`,
|
||||
delay: 5000,
|
||||
})
|
||||
this.toastService.showInfo(
|
||||
$localize`You can restart the tour from the settings page.`
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TestBed } from '@angular/core/testing'
|
||||
import { Toast, ToastLevel, ToastService } from './toast.service'
|
||||
import { ToastService } from './toast.service'
|
||||
|
||||
describe('ToastService', () => {
|
||||
let toastService: ToastService
|
||||
@@ -68,53 +68,6 @@ 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',
|
||||
@@ -153,46 +106,4 @@ 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,12 +2,6 @@ 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
|
||||
|
||||
@@ -15,32 +9,15 @@ export interface Toast {
|
||||
|
||||
delay: number
|
||||
|
||||
delayRemaining?: number
|
||||
|
||||
action?: any
|
||||
|
||||
actionName?: string
|
||||
|
||||
/** Defaults to ToastLevel.Info */
|
||||
level?: ToastLevel
|
||||
classname?: string
|
||||
|
||||
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({
|
||||
@@ -65,51 +42,29 @@ 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) {}
|
||||
}
|
||||
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.toasts.unshift(toast)
|
||||
if (!this._suppressPopupToasts) {
|
||||
this.showToast.next(toast)
|
||||
}
|
||||
this.toastsSubject.next(this.toasts)
|
||||
}
|
||||
|
||||
showError(content: string, error: any = null, delay: number = 10000) {
|
||||
this.show({
|
||||
content: content,
|
||||
delay: delay,
|
||||
level: ToastLevel.Error,
|
||||
classname: 'error',
|
||||
error,
|
||||
})
|
||||
}
|
||||
|
||||
showInfo(content: string, delay: number = 5000) {
|
||||
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)
|
||||
this.show({ content: content, delay: delay })
|
||||
}
|
||||
|
||||
closeToast(toast: Toast) {
|
||||
|
||||
@@ -5,15 +5,11 @@ 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({
|
||||
@@ -21,8 +17,6 @@ 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', () => {
|
||||
@@ -62,47 +56,4 @@ 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,16 +4,12 @@ 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())
|
||||
@@ -38,34 +34,4 @@ 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)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -54,31 +54,6 @@ describe('UploadDocumentsService', () => {
|
||||
req[0].flush('123-456')
|
||||
})
|
||||
|
||||
it('uploads each file when given multiple files', () => {
|
||||
const uploadSpy = jest.spyOn(uploadDocumentsService, 'uploadFile')
|
||||
const file1 = new File(['one'], 'one.pdf')
|
||||
const file2 = new File(['two'], 'two.pdf')
|
||||
uploadDocumentsService.uploadFiles([file1, file2])
|
||||
expect(uploadSpy).toHaveBeenCalledWith(file1)
|
||||
expect(uploadSpy).toHaveBeenCalledWith(file2)
|
||||
httpTestingController
|
||||
.match(`${environment.apiBaseUrl}documents/post_document/`)
|
||||
.forEach((req) => req.flush('123-456'))
|
||||
})
|
||||
|
||||
it('emits when uploads start', () => {
|
||||
const startedSpy = jest.fn()
|
||||
uploadDocumentsService.onUploadsStarted().subscribe(startedSpy)
|
||||
uploadDocumentsService.uploadFiles([])
|
||||
expect(startedSpy).not.toHaveBeenCalled()
|
||||
const file = new File(['one'], 'one.pdf')
|
||||
uploadDocumentsService.uploadFiles([file])
|
||||
expect(startedSpy).toHaveBeenCalledWith([file])
|
||||
httpTestingController
|
||||
.match(`${environment.apiBaseUrl}documents/post_document/`)
|
||||
.forEach((req) => req.flush('123-456'))
|
||||
})
|
||||
|
||||
it('updates progress during upload and failure', () => {
|
||||
const file = new File(
|
||||
[new Blob(['testing'], { type: 'application/pdf' })],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { HttpEventType } from '@angular/common/http'
|
||||
import { Injectable, inject } from '@angular/core'
|
||||
import { Subject, Subscription } from 'rxjs'
|
||||
import { Subscription } from 'rxjs'
|
||||
import { DocumentService } from './rest/document.service'
|
||||
import {
|
||||
FileStatusPhase,
|
||||
@@ -16,21 +16,6 @@ export class UploadDocumentsService {
|
||||
|
||||
private uploadSubscriptions: Array<Subscription> = []
|
||||
|
||||
private readonly uploadsStartedSubject = new Subject<File[]>()
|
||||
|
||||
public uploadFiles(files: Iterable<File>) {
|
||||
const started: File[] = []
|
||||
for (const file of files) {
|
||||
this.uploadFile(file)
|
||||
started.push(file)
|
||||
}
|
||||
if (started.length) this.uploadsStartedSubject.next(started)
|
||||
}
|
||||
|
||||
public onUploadsStarted() {
|
||||
return this.uploadsStartedSubject.asObservable()
|
||||
}
|
||||
|
||||
public uploadFile(file: File) {
|
||||
let formData = new FormData()
|
||||
formData.append('document', file, file.name)
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 200 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user