Compare commits

..
17 Commits
Author SHA1 Message Date
GitHub Actions f730513e79 Auto translate strings 2026-10-20 05:27:06 +00:00
shamoon d4826f50c9 Fix: prevent doc permissions queries on schema view (#14503) 2026-10-19 22:25:52 -07:00
GitHub Actions 8bbca8a269 Auto translate strings 2026-10-20 03:34:03 +00:00
shamoon 3374debd6a Enhancement: add upload count to button, link to tray, reduce notifications (#14501) 2026-10-19 20:32:35 -07:00
GitHub Actions f8e93a535a Auto translate strings 2026-10-20 02:19:58 +00:00
shamoon 98fb7c3b63 Enhancement: notifications improvements (#14500) 2026-10-19 19:18:39 -07:00
GitHub Actions c657fceea5 Auto translate strings 2026-10-20 02:09:30 +00:00
shamoon d76943d57c Enhancement: doc details ux improvements (#14485) 2026-10-19 19:08:33 -07:00
shamoon a4003df7e8 Enhancement (QoL): allow independently toggling child + parent tags in WF tag fields (#14400) 2026-10-19 19:08:21 -07:00
shamoon f6f420a491 Tweak: drop the user dropdown caret (#14444) 2026-10-19 19:08:07 -07:00
shamoon c105486b6a Enhancement: navbar upload button (#14442) 2026-10-19 19:07:55 -07:00
GitHub Actions 8ea31cb3ce Auto translate strings 2026-10-20 02:05:59 +00:00
shamoon 4c124caee1 Enhancement: AI chat UX tweaks (#14415) 2026-10-19 19:04:50 -07:00
GitHub Actions 9e83864f83 Auto translate strings 2026-10-20 01:53:57 +00:00
shamoon 7b53a4b316 Enhancement: support markdown in AI chat (#14409) 2026-10-19 18:52:28 -07:00
shamoon 20b7a116fd Fix: dont defer tag assignment when barcode will not result in a split (#14497) 2026-10-19 15:37:04 -07:00
github-actions[bot] 8d3902454e Documentation: Add v3.3.3 changelog (#14492)
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
2026-10-19 10:28:42 -07:00
106 changed files with 3074 additions and 1321 deletions

No files matched your search

+33
View File
@@ -1,5 +1,38 @@
# Changelog # Changelog
## paperless-ngx 3.3.3
### Bug Fixes
- Fix: fix global search input layout shifting [@shamoon](https://github.com/shamoon) ([#14491](https://github.com/paperless-ngx/paperless-ngx/pull/14491))
- Fix: dont include doc content on global search results [@shamoon](https://github.com/shamoon) ([#14488](https://github.com/paperless-ngx/paperless-ngx/pull/14488))
- Fix: remove some redundant pagers [@shamoon](https://github.com/shamoon) ([#14487](https://github.com/paperless-ngx/paperless-ngx/pull/14487))
- Fix: prevent weird wrapping of dashboard widget column titles [@shamoon](https://github.com/shamoon) ([#14486](https://github.com/paperless-ngx/paperless-ngx/pull/14486))
- Fix: dont paginate 'all' tasks view across sections [@shamoon](https://github.com/shamoon) ([#14484](https://github.com/paperless-ngx/paperless-ngx/pull/14484))
- Fix: use float parsing for importer manifest records with a passphrase [@stumpylog](https://github.com/stumpylog) ([#14483](https://github.com/paperless-ngx/paperless-ngx/pull/14483))
- Fix: respect subpath in logout, share link and auto-login redirects [@jaimevelaz](https://github.com/jaimevelaz) ([#14476](https://github.com/paperless-ngx/paperless-ngx/pull/14476))
- Fix: dont tie suggestions button to ai enabled any more [@shamoon](https://github.com/shamoon) ([#14478](https://github.com/paperless-ngx/paperless-ngx/pull/14478))
- Fix: handle single-class classifier in threshold prediction (#<!---->14471) [@stumpylog](https://github.com/stumpylog) ([#14473](https://github.com/paperless-ngx/paperless-ngx/pull/14473))
- Fix: keep counter and extension when shortening long public filenames [@stumpylog](https://github.com/stumpylog) ([#14472](https://github.com/paperless-ngx/paperless-ngx/pull/14472))
### All App Changes
<details>
<summary>10 changes</summary>
- Fix: fix global search input layout shifting [@shamoon](https://github.com/shamoon) ([#14491](https://github.com/paperless-ngx/paperless-ngx/pull/14491))
- Fix: dont include doc content on global search results [@shamoon](https://github.com/shamoon) ([#14488](https://github.com/paperless-ngx/paperless-ngx/pull/14488))
- Fix: remove some redundant pagers [@shamoon](https://github.com/shamoon) ([#14487](https://github.com/paperless-ngx/paperless-ngx/pull/14487))
- Fix: prevent weird wrapping of dashboard widget column titles [@shamoon](https://github.com/shamoon) ([#14486](https://github.com/paperless-ngx/paperless-ngx/pull/14486))
- Fix: dont paginate 'all' tasks view across sections [@shamoon](https://github.com/shamoon) ([#14484](https://github.com/paperless-ngx/paperless-ngx/pull/14484))
- Fix: use float parsing for importer manifest records with a passphrase [@stumpylog](https://github.com/stumpylog) ([#14483](https://github.com/paperless-ngx/paperless-ngx/pull/14483))
- Fix: respect subpath in logout, share link and auto-login redirects [@jaimevelaz](https://github.com/jaimevelaz) ([#14476](https://github.com/paperless-ngx/paperless-ngx/pull/14476))
- Fix: dont tie suggestions button to ai enabled any more [@shamoon](https://github.com/shamoon) ([#14478](https://github.com/paperless-ngx/paperless-ngx/pull/14478))
- Fix: handle single-class classifier in threshold prediction (#<!---->14471) [@stumpylog](https://github.com/stumpylog) ([#14473](https://github.com/paperless-ngx/paperless-ngx/pull/14473))
- Fix: keep counter and extension when shortening long public filenames [@stumpylog](https://github.com/stumpylog) ([#14472](https://github.com/paperless-ngx/paperless-ngx/pull/14472))
</details>
## paperless-ngx 3.3.2 ## paperless-ngx 3.3.2
### Bug Fixes ### Bug Fixes
+1 -1
View File
@@ -28,7 +28,7 @@ module.exports = {
'abstract-paperless-service', 'abstract-paperless-service',
], ],
transformIgnorePatterns: [ transformIgnorePatterns: [
'node_modules/(?!.*(\\.mjs$|tslib|lodash-es|normalize-diacritics|@angular/common/locales/.*\\.js$))', 'node_modules/(?!.*(\\.mjs$|tslib|lodash-es|normalize-diacritics|marked|@angular/common/locales/.*\\.js$))',
], ],
moduleNameMapper: { moduleNameMapper: {
...esmPreset.moduleNameMapper, ...esmPreset.moduleNameMapper,
+665 -561
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -30,6 +30,7 @@
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"lodash-es": "^4.18.1", "lodash-es": "^4.18.1",
"marked": "~18.0.14",
"mime-names": "^1.0.0", "mime-names": "^1.0.0",
"ngx-bootstrap-icons": "^1.9.3", "ngx-bootstrap-icons": "^1.9.3",
"ngx-color": "^10.1.0", "ngx-color": "^10.1.0",
+10
View File
@@ -53,6 +53,9 @@ importers:
lodash-es: lodash-es:
specifier: ^4.18.1 specifier: ^4.18.1
version: 4.18.1 version: 4.18.1
marked:
specifier: ~18.0.14
version: 18.0.14
mime-names: mime-names:
specifier: ^1.0.0 specifier: ^1.0.0
version: 1.0.0 version: 1.0.0
@@ -3226,6 +3229,11 @@ packages:
make-error@1.3.6: make-error@1.3.6:
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} 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: material-colors@1.2.6:
resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==} resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==}
@@ -7409,6 +7417,8 @@ snapshots:
make-error@1.3.6: {} make-error@1.3.6: {}
marked@18.0.14: {}
material-colors@1.2.6: {} material-colors@1.2.6: {}
merge-stream@2.0.0: {} merge-stream@2.0.0: {}
+15 -9
View File
@@ -19,6 +19,7 @@ import { PermissionsGuard } from './guards/permissions.guard'
import { HotKeyService } from './services/hot-key.service' import { HotKeyService } from './services/hot-key.service'
import { PermissionsService } from './services/permissions.service' import { PermissionsService } from './services/permissions.service'
import { SettingsService } from './services/settings.service' import { SettingsService } from './services/settings.service'
import { TasksService } from './services/tasks.service'
import { Toast, ToastService } from './services/toast.service' import { Toast, ToastService } from './services/toast.service'
import { import {
FileStatus, FileStatus,
@@ -34,6 +35,7 @@ describe('AppComponent', () => {
let toastService: ToastService let toastService: ToastService
let router: Router let router: Router
let settingsService: SettingsService let settingsService: SettingsService
let tasksService: TasksService
let hotKeyService: HotKeyService let hotKeyService: HotKeyService
beforeEach(async () => { beforeEach(async () => {
@@ -60,6 +62,7 @@ describe('AppComponent', () => {
websocketStatusService = TestBed.inject(WebsocketStatusService) websocketStatusService = TestBed.inject(WebsocketStatusService)
permissionsService = TestBed.inject(PermissionsService) permissionsService = TestBed.inject(PermissionsService)
settingsService = TestBed.inject(SettingsService) settingsService = TestBed.inject(SettingsService)
tasksService = TestBed.inject(TasksService)
toastService = TestBed.inject(ToastService) toastService = TestBed.inject(ToastService)
router = TestBed.inject(Router) router = TestBed.inject(Router)
hotKeyService = TestBed.inject(HotKeyService) hotKeyService = TestBed.inject(HotKeyService)
@@ -117,30 +120,33 @@ describe('AppComponent', () => {
expect(toast.action).toBeUndefined() expect(toast.action).toBeUndefined()
}) })
it('should display toast on document added', () => { it('should only reload tasks when a document is detected', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
const toastSpy = jest.spyOn(toastService, 'show') const toastSpy = jest.spyOn(toastService, 'show')
const reloadSpy = jest.spyOn(tasksService, 'reload')
const fileStatusSubject = new Subject<FileStatus>() const fileStatusSubject = new Subject<FileStatus>()
jest jest
.spyOn(websocketStatusService, 'onDocumentDetected') .spyOn(websocketStatusService, 'onDocumentDetected')
.mockReturnValue(fileStatusSubject) .mockReturnValue(fileStatusSubject)
component.ngOnInit() component.ngOnInit()
fileStatusSubject.next(new FileStatus()) fileStatusSubject.next(new FileStatus())
expect(toastSpy).toHaveBeenCalled() expect(reloadSpy).toHaveBeenCalled()
expect(toastSpy).not.toHaveBeenCalled()
}) })
it('should suppress dashboard notifications if set', () => { it('should add consumed documents to the bell without a popup', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest.spyOn(settingsService, 'get').mockReturnValue(true) const popupSpy = jest.fn()
jest.spyOn(router, 'url', 'get').mockReturnValue('/dashboard') toastService.showToast.subscribe(popupSpy)
const toastSpy = jest.spyOn(toastService, 'show') let toasts: Toast[] = []
toastService.getToasts().subscribe((t) => (toasts = [...t]))
const fileStatusSubject = new Subject<FileStatus>() const fileStatusSubject = new Subject<FileStatus>()
jest jest
.spyOn(websocketStatusService, 'onDocumentDetected') .spyOn(websocketStatusService, 'onDocumentConsumptionFinished')
.mockReturnValue(fileStatusSubject) .mockReturnValue(fileStatusSubject)
component.ngOnInit() component.ngOnInit()
fileStatusSubject.next(new FileStatus()) fileStatusSubject.next(new FileStatus())
expect(toastSpy).not.toHaveBeenCalled() expect(toasts).toHaveLength(1)
expect(popupSpy).not.toHaveBeenCalled()
}) })
it('should display toast on document failed', () => { it('should display toast on document failed', () => {
+12 -43
View File
@@ -14,7 +14,7 @@ import {
} from './services/permissions.service' } from './services/permissions.service'
import { SettingsService } from './services/settings.service' import { SettingsService } from './services/settings.service'
import { TasksService } from './services/tasks.service' import { TasksService } from './services/tasks.service'
import { ToastService } from './services/toast.service' import { ToastLevel, ToastService } from './services/toast.service'
import { WebsocketStatusService } from './services/websocket-status.service' import { WebsocketStatusService } from './services/websocket-status.service'
@Component({ @Component({
@@ -61,18 +61,6 @@ export class AppComponent implements OnInit, OnDestroy {
} }
} }
private showNotification(key) {
if (
this.router.url == '/dashboard' &&
this.settings.get(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD
)
) {
return false
}
return this.settings.get(key)
}
ngOnInit(): void { ngOnInit(): void {
this.websocketStatusService.connect() this.websocketStatusService.connect()
@@ -80,29 +68,22 @@ export class AppComponent implements OnInit, OnDestroy {
.onDocumentConsumptionFinished() .onDocumentConsumptionFinished()
.subscribe((status) => { .subscribe((status) => {
this.tasksService.reload() this.tasksService.reload()
if ( if (this.settings.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS)) {
this.showNotification(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS) const canView = this.permissionsService.currentUserCan(
) {
if (
this.permissionsService.currentUserCan(
PermissionAction.View, PermissionAction.View,
PermissionType.Document PermissionType.Document
) )
) { // the upload status tray already shows this, so only keep a record
this.toastService.show({ this.toastService.show({
content: $localize`Document ${status.filename} was added to Paperless-ngx.`, content: $localize`Document ${status.filename} was added to Paperless-ngx.`,
delay: 10000, delay: 10000,
actionName: $localize`Open document`, level: ToastLevel.Success,
action: () => { silent: true,
this.router.navigate(['documents', status.documentId]) actionName: canView ? $localize`Open document` : undefined,
}, action: canView
? () => this.router.navigate(['documents', status.documentId])
: undefined,
}) })
} else {
this.toastService.show({
content: $localize`Document ${status.filename} was added to Paperless-ngx.`,
delay: 10000,
})
}
} }
}) })
@@ -110,9 +91,7 @@ export class AppComponent implements OnInit, OnDestroy {
.onDocumentConsumptionFailed() .onDocumentConsumptionFailed()
.subscribe((status) => { .subscribe((status) => {
this.tasksService.reload() this.tasksService.reload()
if ( if (this.settings.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED)) {
this.showNotification(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED)
) {
this.toastService.showError( this.toastService.showError(
$localize`Could not add ${status.filename}\: ${status.message}` $localize`Could not add ${status.filename}\: ${status.message}`
) )
@@ -121,18 +100,8 @@ export class AppComponent implements OnInit, OnDestroy {
this.newDocumentSubscription = this.websocketStatusService this.newDocumentSubscription = this.websocketStatusService
.onDocumentDetected() .onDocumentDetected()
.subscribe((status) => { .subscribe(() => {
this.tasksService.reload() 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 this.hotKeyService
@@ -190,7 +190,7 @@ export class ConfigComponent
this.initialize(config) this.initialize(config)
this.store.next(config) this.store.next(config)
this.settingsService.initializeSettings().subscribe() this.settingsService.initializeSettings().subscribe()
this.toastService.showInfo($localize`Configuration updated`) this.toastService.showSuccess($localize`Configuration updated`)
}, },
error: (e) => { error: (e) => {
this.loading.set(false) this.loading.set(false)
@@ -217,7 +217,7 @@ export class ConfigComponent
this.initialize(config) this.initialize(config)
this.store.next(config) this.store.next(config)
this.settingsService.initializeSettings().subscribe() this.settingsService.initializeSettings().subscribe()
this.toastService.showInfo($localize`File successfully updated`) this.toastService.showSuccess($localize`File successfully updated`)
}, },
error: (e) => { error: (e) => {
this.loading.set(false) this.loading.set(false)
@@ -425,10 +425,8 @@
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<pngx-input-check i18n-title title="Show notifications when new documents are detected" formControlName="notificationsConsumerNewDocument"></pngx-input-check> <pngx-input-check i18n-title title="Add notifications when document processing completes successfully" formControlName="notificationsConsumerSuccess" i18n-hint hint="These are added to the notifications menu without a popup, processing progress is shown at the bottom of the screen."></pngx-input-check>
<pngx-input-check i18n-title title="Show notifications when document processing 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="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>
</div> </div>
@@ -307,7 +307,7 @@ describe('SettingsComponent', () => {
expect(toastErrorSpy).toHaveBeenCalled() expect(toastErrorSpy).toHaveBeenCalled()
expect(storeSpy).toHaveBeenCalled() expect(storeSpy).toHaveBeenCalled()
expect(appearanceSettingsSpy).not.toHaveBeenCalled() expect(appearanceSettingsSpy).not.toHaveBeenCalled()
expect(setSpy).toHaveBeenCalledTimes(35) expect(setSpy).toHaveBeenCalledTimes(33)
expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [ expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
HideableSidebarItemID.Workflows, HideableSidebarItemID.Workflows,
]) ])
@@ -63,7 +63,7 @@ import {
SettingsService, SettingsService,
} from 'src/app/services/settings.service' } from 'src/app/services/settings.service'
import { SystemStatusService } from 'src/app/services/system-status.service' import { SystemStatusService } from 'src/app/services/system-status.service'
import { Toast, ToastService } from 'src/app/services/toast.service' import { Toast, ToastLevel, ToastService } from 'src/app/services/toast.service'
import { locationReload } from 'src/app/utils/navigation' import { locationReload } from 'src/app/utils/navigation'
import { CheckComponent } from '../../common/input/check/check.component' import { CheckComponent } from '../../common/input/check/check.component'
import { ColorComponent } from '../../common/input/color/color.component' import { ColorComponent } from '../../common/input/color/color.component'
@@ -190,10 +190,8 @@ export class SettingsComponent
searchDbOnly: new FormControl(null), searchDbOnly: new FormControl(null),
searchLink: new FormControl(null), searchLink: new FormControl(null),
notificationsConsumerNewDocument: new FormControl(null),
notificationsConsumerSuccess: new FormControl(null), notificationsConsumerSuccess: new FormControl(null),
notificationsConsumerFailed: new FormControl(null), notificationsConsumerFailed: new FormControl(null),
notificationsConsumerSuppressOnDashboard: new FormControl(null),
savedViewsWarnOnUnsavedChange: new FormControl(null), savedViewsWarnOnUnsavedChange: new FormControl(null),
sidebarViewsShowCount: new FormControl(null), sidebarViewsShowCount: new FormControl(null),
@@ -371,18 +369,12 @@ export class SettingsComponent
updateCheckingEnabled: this.settings.get( updateCheckingEnabled: this.settings.get(
SETTINGS_KEYS.UPDATE_CHECKING_ENABLED SETTINGS_KEYS.UPDATE_CHECKING_ENABLED
), ),
notificationsConsumerNewDocument: this.settings.get(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT
),
notificationsConsumerSuccess: this.settings.get( notificationsConsumerSuccess: this.settings.get(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS
), ),
notificationsConsumerFailed: this.settings.get( notificationsConsumerFailed: this.settings.get(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED
), ),
notificationsConsumerSuppressOnDashboard: this.settings.get(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD
),
savedViewsWarnOnUnsavedChange: this.settings.get( savedViewsWarnOnUnsavedChange: this.settings.get(
SETTINGS_KEYS.SAVED_VIEWS_WARN_ON_UNSAVED_CHANGE SETTINGS_KEYS.SAVED_VIEWS_WARN_ON_UNSAVED_CHANGE
), ),
@@ -571,10 +563,6 @@ export class SettingsComponent
SETTINGS_KEYS.DATE_FORMAT, SETTINGS_KEYS.DATE_FORMAT,
this.settingsForm.value.dateFormat this.settingsForm.value.dateFormat
) )
this.settings.set(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT,
this.settingsForm.value.notificationsConsumerNewDocument
)
this.settings.set( this.settings.set(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS, SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS,
this.settingsForm.value.notificationsConsumerSuccess this.settingsForm.value.notificationsConsumerSuccess
@@ -583,10 +571,6 @@ export class SettingsComponent
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED, SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED,
this.settingsForm.value.notificationsConsumerFailed this.settingsForm.value.notificationsConsumerFailed
) )
this.settings.set(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD,
this.settingsForm.value.notificationsConsumerSuppressOnDashboard
)
this.settings.set( this.settings.set(
SETTINGS_KEYS.NOTES_ENABLED, SETTINGS_KEYS.NOTES_ENABLED,
this.settingsForm.value.notesEnabled this.settingsForm.value.notesEnabled
@@ -664,8 +648,11 @@ export class SettingsComponent
let savedToast: Toast = { let savedToast: Toast = {
content: $localize`Settings were saved successfully.`, content: $localize`Settings were saved successfully.`,
delay: 5000, delay: 5000,
level: ToastLevel.Success,
persistent: false,
} }
if (reloadRequired) { if (reloadRequired) {
savedToast.persistent = true
savedToast.content = $localize`Settings were saved successfully. Reload is required to apply some changes.` savedToast.content = $localize`Settings were saved successfully. Reload is required to apply some changes.`
savedToast.actionName = $localize`Reload now` savedToast.actionName = $localize`Reload now`
savedToast.action = () => { savedToast.action = () => {
@@ -99,11 +99,16 @@ describe('TrashComponent', () => {
modal.componentInstance.confirmClicked.next() modal.componentInstance.confirmClicked.next()
expect(toastErrorSpy).toHaveBeenCalled() expect(toastErrorSpy).toHaveBeenCalled()
let toasts
toastService.getToasts().subscribe((allToasts) => {
toasts = [...allToasts]
})
trashSpy.mockReturnValue(of('OK')) trashSpy.mockReturnValue(of('OK'))
component.delete(documentsInTrash[0]) component.delete(documentsInTrash[0])
expect(modal).toBeDefined() expect(modal).toBeDefined()
modal.componentInstance.confirmClicked.next() modal.componentInstance.confirmClicked.next()
expect(trashSpy).toHaveBeenCalled() expect(trashSpy).toHaveBeenCalled()
expect(toasts[0].content).toContain('deleted')
}) })
it('should support empty trash, show error if needed', () => { 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 { Document } from 'src/app/data/document'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastLevel, ToastService } from 'src/app/services/toast.service'
import { TrashService } from 'src/app/services/trash.service' import { TrashService } from 'src/app/services/trash.service'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component' import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.component' import { PageHeaderComponent } from '../../common/page-header/page-header.component'
@@ -87,9 +87,11 @@ export class TrashComponent
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled.set(false)
this.trashService.emptyTrash([document.id]).subscribe({ this.trashService.emptyTrash([document.id]).subscribe({
next: () => { next: () => {
this.toastService.showInfo( this.toastService.show({
$localize`Document "${document.title}" deleted` content: $localize`Document "${document.title}" deleted`,
) delay: 5000,
level: ToastLevel.Success,
})
modal.close() modal.close()
this.reload() this.reload()
}, },
@@ -122,7 +124,11 @@ export class TrashComponent
.emptyTrash(documents ? Array.from(documents) : null) .emptyTrash(documents ? Array.from(documents) : null)
.subscribe({ .subscribe({
next: () => { next: () => {
this.toastService.showInfo($localize`Document(s) deleted`) this.toastService.show({
content: $localize`Document(s) deleted`,
delay: 5000,
level: ToastLevel.Success,
})
this.allToggled.set(false) this.allToggled.set(false)
modal.close() modal.close()
this.reload() this.reload()
@@ -144,6 +150,7 @@ export class TrashComponent
this.toastService.show({ this.toastService.show({
content: $localize`Document "${document.title}" restored`, content: $localize`Document "${document.title}" restored`,
delay: 5000, delay: 5000,
level: ToastLevel.Success,
actionName: $localize`Open document`, actionName: $localize`Open document`,
action: () => { action: () => {
this.router.navigate(['documents', document.id]) this.router.navigate(['documents', document.id])
@@ -165,7 +172,11 @@ export class TrashComponent
.restoreDocuments(documents ? Array.from(documents) : null) .restoreDocuments(documents ? Array.from(documents) : null)
.subscribe({ .subscribe({
next: () => { next: () => {
this.toastService.showInfo($localize`Document(s) restored`) this.toastService.show({
content: $localize`Document(s) restored`,
delay: 5000,
level: ToastLevel.Success,
})
this.allToggled.set(false) this.allToggled.set(false)
this.reload() this.reload()
}, },
@@ -101,7 +101,7 @@ describe('UsersAndGroupsComponent', () => {
component.editUser(users[0]) component.editUser(users[0])
const editDialog = modal.componentInstance as UserEditDialogComponent const editDialog = modal.componentInstance as UserEditDialogComponent
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
editDialog.failed.emit() editDialog.failed.emit()
expect(toastErrorSpy).toHaveBeenCalled() expect(toastErrorSpy).toHaveBeenCalled()
settingsService.currentUser.set(users[1]) // simulate logged in as different user settingsService.currentUser.set(users[1]) // simulate logged in as different user
@@ -120,7 +120,7 @@ describe('UsersAndGroupsComponent', () => {
const deleteDialog = modal.componentInstance as ConfirmDialogComponent const deleteDialog = modal.componentInstance as ConfirmDialogComponent
const deleteSpy = jest.spyOn(userService, 'delete') const deleteSpy = jest.spyOn(userService, 'delete')
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const listAllSpy = jest.spyOn(userService, 'listAll') const listAllSpy = jest.spyOn(userService, 'listAll')
deleteSpy.mockReturnValueOnce( deleteSpy.mockReturnValueOnce(
throwError(() => new Error('error deleting user')) throwError(() => new Error('error deleting user'))
@@ -186,7 +186,7 @@ describe('UsersAndGroupsComponent', () => {
component.editGroup(groups[0]) component.editGroup(groups[0])
const editDialog = modal.componentInstance as GroupEditDialogComponent const editDialog = modal.componentInstance as GroupEditDialogComponent
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
editDialog.failed.emit() editDialog.failed.emit()
expect(toastErrorSpy).toHaveBeenCalled() expect(toastErrorSpy).toHaveBeenCalled()
editDialog.succeeded.emit(groups[0]) editDialog.succeeded.emit(groups[0])
@@ -204,7 +204,7 @@ describe('UsersAndGroupsComponent', () => {
const deleteDialog = modal.componentInstance as ConfirmDialogComponent const deleteDialog = modal.componentInstance as ConfirmDialogComponent
const deleteSpy = jest.spyOn(groupService, 'delete') const deleteSpy = jest.spyOn(groupService, 'delete')
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const listAllSpy = jest.spyOn(groupService, 'listAll') const listAllSpy = jest.spyOn(groupService, 'listAll')
deleteSpy.mockReturnValueOnce( deleteSpy.mockReturnValueOnce(
throwError(() => new Error('error deleting group')) throwError(() => new Error('error deleting group'))
@@ -122,7 +122,7 @@ export class UsersAndGroupsComponent
) )
}, 2500) }, 2500)
} else { } else {
this.toastService.showInfo( this.toastService.showSuccess(
$localize`Saved user "${newUser.username}".` $localize`Saved user "${newUser.username}".`
) )
this.usersService.listAll().subscribe((r) => { this.usersService.listAll().subscribe((r) => {
@@ -151,7 +151,9 @@ export class UsersAndGroupsComponent
this.usersService.delete(user).subscribe({ this.usersService.delete(user).subscribe({
next: () => { next: () => {
modal.close() modal.close()
this.toastService.showInfo($localize`Deleted user "${user.username}"`) this.toastService.showSuccess(
$localize`Deleted user "${user.username}"`
)
this.usersService.listAll().subscribe((r) => { this.usersService.listAll().subscribe((r) => {
this.users.set(r.results) this.users.set(r.results)
}) })
@@ -178,7 +180,9 @@ export class UsersAndGroupsComponent
modal.componentInstance.succeeded modal.componentInstance.succeeded
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((newGroup) => { .subscribe((newGroup) => {
this.toastService.showInfo($localize`Saved group "${newGroup.name}".`) this.toastService.showSuccess(
$localize`Saved group "${newGroup.name}".`
)
this.groupsService.listAll().subscribe((r) => { this.groupsService.listAll().subscribe((r) => {
this.groups.set(r.results) this.groups.set(r.results)
}) })
@@ -204,7 +208,9 @@ export class UsersAndGroupsComponent
this.groupsService.delete(group).subscribe({ this.groupsService.delete(group).subscribe({
next: () => { next: () => {
modal.close() modal.close()
this.toastService.showInfo($localize`Deleted group "${group.name}"`) this.toastService.showSuccess(
$localize`Deleted group "${group.name}"`
)
this.groupsService.listAll().subscribe((r) => { this.groupsService.listAll().subscribe((r) => {
this.groups.set(r.results) this.groups.set(r.results)
}) })
@@ -29,8 +29,20 @@
</div> </div>
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 me-sm-auto order-3 order-sm-1" <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()"> [class.mobile-hidden]="mobileSearchHidden()">
<div class="col-12 header-search mx-auto"> <div class="col-12 header-search mx-auto d-flex align-items-stretch gap-3">
<pngx-global-search></pngx-global-search> <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> </div>
</div> </div>
<ul ngbNav class="order-sm-3"> <ul ngbNav class="order-sm-3">
@@ -70,6 +82,9 @@
</ul> </ul>
</nav> </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="container-fluid">
<div class="row"> <div class="row">
<nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse" <nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse"
@@ -419,5 +434,6 @@
[ngClass]="slimSidebarEnabled ? 'col-slim' : 'col-sidebar-expanded'"> [ngClass]="slimSidebarEnabled ? 'col-slim' : 'col-sidebar-expanded'">
<router-outlet></router-outlet> <router-outlet></router-outlet>
</main> </main>
<pngx-upload-status-tray></pngx-upload-status-tray>
</div> </div>
</div> </div>
@@ -538,7 +538,36 @@ main {
.header-search { .header-search {
width: 100%; width: 100%;
max-width: 55rem; 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);
} }
@media screen and (max-width: 575.98px) { @media screen and (max-width: 575.98px) {
@@ -630,8 +659,7 @@ main {
} }
.dropdown-toggle::after { .dropdown-toggle::after {
margin-left: 0.4em; display: none;
vertical-align: 0.155em;
} }
.navbar .dropdown-menu { .navbar .dropdown-menu {
@@ -668,3 +696,7 @@ main {
::ng-deep .navItemDrag .position-absolute i-bs { ::ng-deep .navItemDrag .position-absolute i-bs {
display: none; display: none;
} }
.smaller {
font-size: 0.75rem;
}
@@ -6,7 +6,7 @@ import {
} from '@angular/common/http/testing' } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing' import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { BrowserModule } from '@angular/platform-browser' import { BrowserModule, By } from '@angular/platform-browser'
import { ActivatedRoute, Router } from '@angular/router' import { ActivatedRoute, Router } from '@angular/router'
import { RouterTestingModule } from '@angular/router/testing' import { RouterTestingModule } from '@angular/router/testing'
import { NgbModal, NgbModalModule, NgbModule } from '@ng-bootstrap/ng-bootstrap' import { NgbModal, NgbModalModule, NgbModule } from '@ng-bootstrap/ng-bootstrap'
@@ -33,8 +33,11 @@ import { SearchService } from 'src/app/services/rest/search.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { TasksService } from 'src/app/services/tasks.service' import { TasksService } from 'src/app/services/tasks.service'
import { ToastService } from 'src/app/services/toast.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 { environment } from 'src/environments/environment'
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component' import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
import { UploadStatusTrayComponent } from '../common/upload-status-tray/upload-status-tray.component'
import { DocumentDetailComponent } from '../document-detail/document-detail.component' import { DocumentDetailComponent } from '../document-detail/document-detail.component'
import { AppFrameComponent } from './app-frame.component' import { AppFrameComponent } from './app-frame.component'
import { GlobalSearchComponent } from './global-search/global-search.component' import { GlobalSearchComponent } from './global-search/global-search.component'
@@ -462,6 +465,45 @@ describe('AppFrameComponent', () => {
expect(routerSpy).toHaveBeenCalled() 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', () => { it('should close all documents on logout', () => {
const closeAllSpy = jest.spyOn(openDocumentsService, 'closeAll') const closeAllSpy = jest.spyOn(openDocumentsService, 'closeAll')
component.onLogout() component.onLogout()
@@ -483,7 +525,7 @@ describe('AppFrameComponent', () => {
it('should update saved view sorting on drag + drop, show info', () => { it('should update saved view sorting on drag + drop, show info', () => {
const settingsSpy = jest.spyOn(settingsService, 'updateSidebarViewsSort') const settingsSpy = jest.spyOn(settingsService, 'updateSidebarViewsSort')
const toastSpy = jest.spyOn(toastService, 'showInfo') const toastSpy = jest.spyOn(toastService, 'showSuccess')
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true)) jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
component.onDrop({ previousIndex: 0, currentIndex: 1 } as CdkDragDrop< component.onDrop({ previousIndex: 0, currentIndex: 1 } as CdkDragDrop<
SavedView[] SavedView[]
@@ -525,6 +567,7 @@ describe('AppFrameComponent', () => {
it('should show toasts for django messages', () => { it('should show toasts for django messages', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
jest.spyOn(messagesService, 'get').mockReturnValue([ jest.spyOn(messagesService, 'get').mockReturnValue([
{ level: DjangoMessageLevel.WARNING, message: 'Test warning' }, { level: DjangoMessageLevel.WARNING, message: 'Test warning' },
@@ -535,7 +578,8 @@ describe('AppFrameComponent', () => {
]) ])
component.ngOnInit() component.ngOnInit()
expect(toastErrorSpy).toHaveBeenCalledTimes(2) expect(toastErrorSpy).toHaveBeenCalledTimes(2)
expect(toastInfoSpy).toHaveBeenCalledTimes(3) expect(toastSuccessSpy).toHaveBeenCalledTimes(1)
expect(toastInfoSpy).toHaveBeenCalledTimes(2)
}) })
it('should call maybeRefreshDocumentCounts after saved views reload', () => { it('should call maybeRefreshDocumentCounts after saved views reload', () => {
@@ -6,7 +6,14 @@ import {
moveItemInArray, moveItemInArray,
} from '@angular/cdk/drag-drop' } from '@angular/cdk/drag-drop'
import { NgClass } from '@angular/common' import { NgClass } from '@angular/common'
import { Component, HostListener, inject, OnInit, signal } from '@angular/core' import {
Component,
HostListener,
inject,
OnInit,
signal,
viewChild,
} from '@angular/core'
import { FormsModule } from '@angular/forms' import { FormsModule } from '@angular/forms'
import { ActivatedRoute, Router, RouterModule } from '@angular/router' import { ActivatedRoute, Router, RouterModule } from '@angular/router'
import { import {
@@ -48,12 +55,15 @@ import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { TasksService } from 'src/app/services/tasks.service' import { TasksService } from 'src/app/services/tasks.service'
import { ToastService } from 'src/app/services/toast.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 { environment } from 'src/environments/environment'
import { ChatComponent } from '../chat/chat/chat.component' 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 { BrandMarkComponent } from '../common/logo/brand-mark/brand-mark.component'
import { LogoComponent } from '../common/logo/logo.component' import { LogoComponent } from '../common/logo/logo.component'
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component' import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
import { SwitchComponent } from '../common/input/switch/switch.component' import { UploadStatusTrayComponent } from '../common/upload-status-tray/upload-status-tray.component'
import { DocumentDetailComponent } from '../document-detail/document-detail.component' import { DocumentDetailComponent } from '../document-detail/document-detail.component'
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component' import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
import { GlobalSearchComponent } from './global-search/global-search.component' import { GlobalSearchComponent } from './global-search/global-search.component'
@@ -84,6 +94,7 @@ const SCROLL_THRESHOLD = 16
TourNgBootstrap, TourNgBootstrap,
FormsModule, FormsModule,
SwitchComponent, SwitchComponent,
UploadStatusTrayComponent,
], ],
}) })
export class AppFrameComponent export class AppFrameComponent
@@ -101,6 +112,10 @@ export class AppFrameComponent
private modalService = inject(NgbModal) private modalService = inject(NgbModal)
permissionsService = inject(PermissionsService) permissionsService = inject(PermissionsService)
private djangoMessagesService = inject(DjangoMessagesService) private djangoMessagesService = inject(DjangoMessagesService)
private uploadDocumentsService = inject(UploadDocumentsService)
private websocketStatusService = inject(WebsocketStatusService)
private readonly uploadStatusTray = viewChild(UploadStatusTrayComponent)
readonly appRemoteVersion = signal<AppRemoteVersion>(null) readonly appRemoteVersion = signal<AppRemoteVersion>(null)
readonly isMenuCollapsed = signal(true) readonly isMenuCollapsed = signal(true)
@@ -171,6 +186,8 @@ export class AppFrameComponent
this.toastService.showError(message.message) this.toastService.showError(message.message)
break break
case DjangoMessageLevel.SUCCESS: case DjangoMessageLevel.SUCCESS:
this.toastService.showSuccess(message.message)
break
case DjangoMessageLevel.INFO: case DjangoMessageLevel.INFO:
case DjangoMessageLevel.DEBUG: case DjangoMessageLevel.DEBUG:
this.toastService.showInfo(message.message) this.toastService.showInfo(message.message)
@@ -477,7 +494,7 @@ export class AppFrameComponent
this.settingsService.updateSidebarViewsSort(sidebarViews).subscribe({ this.settingsService.updateSidebarViewsSort(sidebarViews).subscribe({
next: () => { next: () => {
this.toastService.showInfo($localize`Sidebar views updated`) this.toastService.showSuccess($localize`Sidebar views updated`)
}, },
error: (e) => { error: (e) => {
this.toastService.showError($localize`Error updating sidebar views`, e) this.toastService.showError($localize`Error updating sidebar views`, e)
@@ -511,6 +528,22 @@ 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() { onLogout() {
this.openDocumentsService.closeAll() this.openDocumentsService.closeAll()
} }
@@ -409,7 +409,7 @@ describe('GlobalSearchComponent', () => {
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
// fail first // fail first
editDialog.failed.emit({ error: 'error creating item' }) editDialog.failed.emit({ error: 'error creating item' })
@@ -460,7 +460,7 @@ describe('GlobalSearchComponent', () => {
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
// fail first // fail first
editDialog.failed.emit({ error: 'error creating item' }) editDialog.failed.emit({ error: 'error creating item' })
@@ -216,7 +216,7 @@ export class GlobalSearchComponent implements OnInit {
modalRef.componentInstance.dialogMode.set(EditDialogMode.EDIT) modalRef.componentInstance.dialogMode.set(EditDialogMode.EDIT)
modalRef.componentInstance.object = object modalRef.componentInstance.object = object
modalRef.componentInstance.succeeded.subscribe(() => { modalRef.componentInstance.succeeded.subscribe(() => {
this.toastService.showInfo($localize`Successfully updated object.`) this.toastService.showSuccess($localize`Successfully updated object.`)
}) })
modalRef.componentInstance.failed.subscribe((e) => { modalRef.componentInstance.failed.subscribe((e) => {
this.toastService.showError($localize`Error occurred saving object.`, 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.dialogMode.set(EditDialogMode.EDIT)
modalRef.componentInstance.object = object modalRef.componentInstance.object = object
modalRef.componentInstance.succeeded.subscribe(() => { modalRef.componentInstance.succeeded.subscribe(() => {
this.toastService.showInfo($localize`Successfully updated object.`) this.toastService.showSuccess($localize`Successfully updated object.`)
}) })
modalRef.componentInstance.failed.subscribe((e) => { modalRef.componentInstance.failed.subscribe((e) => {
this.toastService.showError($localize`Error occurred saving object.`, e) this.toastService.showError($localize`Error occurred saving object.`, e)
@@ -1,27 +1,30 @@
<li ngbDropdown class="nav-item position-relative" (openChange)="onOpenChange($event)"> <li ngbDropdown class="nav-item position-relative" (openChange)="onOpenChange($event)">
@if (toasts().length) { @if (unread().length) {
<span class="notification-count badge rounded-pill z-3 pe-none bg-secondary position-absolute">{{ toasts().length }}</span> <span class="notification-count badge rounded-pill z-3 pe-none position-absolute" [class.bg-danger]="hasUnreadError()" [class.bg-secondary]="!hasUnreadError()">{{ unread().length }}</span>
} }
<button class="btn navbar-action border-0" id="notificationsDropdown" ngbDropdownToggle aria-label="Notifications" i18n-aria-label> <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> <i-bs width="1.3em" height="1.3em" name="bell"></i-bs>
</button> </button>
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="notificationsDropdown"> <div ngbDropdownMenu class="dropdown-menu-end shadow p-2" aria-labelledby="notificationsDropdown">
<div class="btn-toolbar align-items-center" role="toolbar"> <div class="d-flex align-items-center px-1 mb-2">
<h6 i18n>Notifications</h6> <h6 class="mb-0" i18n>Notifications</h6>
<div class="btn-group ms-auto"> @if (toasts().length) {
<button class="btn btn-sm btn-outline-secondary mb-2 ms-auto" <button class="btn btn-link btn-sm p-0 ms-auto text-body-secondary text-decoration-none clear-all"
(click)="toastService.clearToasts()" (click)="toastService.clearToasts()">
[disabled]="toasts().length === 0" <i-bs width="1em" height="1em" name="check2-all" class="me-1"></i-bs><ng-container i18n>Clear All</ng-container>
i18n>Clear All</button> </button>
</div> }
</div> </div>
@if (toasts().length === 0) { @if (toasts().length === 0) {
<p class="text-center mb-0 small text-muted"><em i18n>No notifications</em></p> <div class="text-center text-body-secondary small py-4">
<i-bs width="1.5em" height="1.5em" name="bell" class="d-block mx-auto mb-2 opacity-50"></i-bs>
<ng-container i18n>No notifications</ng-container>
</div>
} }
<div class="scroll-list"> <div class="scroll-list d-flex flex-column gap-2">
@for (toast of toasts(); track toast.id) { @for (toast of toasts(); track toast.id) {
<pngx-toast [autohide]="false" [toast]="toast" (hidden)="onHidden(toast)" (closed)="toastService.closeToast(toast)"></pngx-toast> <pngx-toast [autohide]="false" [toast]="toast" [now]="now()" [isNew]="newIds().has(toast.id)" (closed)="toastService.closeToast(toast)" (actioned)="onActioned(toast)"></pngx-toast>
} }
</div> </div>
</div> </div>
@@ -30,3 +30,7 @@
right: -3rem; right: -3rem;
} }
} }
.clear-all:hover {
color: var(--bs-body-color) !important;
}
@@ -3,7 +3,7 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing' import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { Subject } from 'rxjs' import { Subject } from 'rxjs'
import { Toast, ToastService } from 'src/app/services/toast.service' import { Toast, ToastLevel, ToastService } from 'src/app/services/toast.service'
import { ToastsDropdownComponent } from './toasts-dropdown.component' import { ToastsDropdownComponent } from './toasts-dropdown.component'
const toasts = [ const toasts = [
@@ -90,4 +90,73 @@ describe('ToastsDropdownComponent', () => {
component.onOpenChange(true) component.onOpenChange(true)
expect(spy).toHaveBeenCalledWith(true) expect(spy).toHaveBeenCalledWith(true)
}) })
it('should badge unread toasts, red when one is an error', () => {
toastsSubject.next([
{ id: 'a', content: 'Read', delay: 5000, read: true },
{ id: 'b', content: 'Unread', delay: 5000, read: false },
])
fixture.detectChanges()
let badge: HTMLElement = fixture.nativeElement.querySelector(
'.notification-count'
)
expect(badge.textContent.trim()).toEqual('1')
expect(badge.classList).toContain('bg-secondary')
toastsSubject.next([
{ id: 'c', content: 'Failed', delay: 5000, level: ToastLevel.Error },
])
fixture.detectChanges()
badge = fixture.nativeElement.querySelector('.notification-count')
expect(badge.classList).toContain('bg-danger')
toastsSubject.next([{ id: 'd', content: 'Seen', delay: 5000, read: true }])
fixture.detectChanges()
expect(
fixture.nativeElement.querySelector('.notification-count')
).toBeNull()
})
it('should mark all toasts read when opened', () => {
const markSpy = jest.spyOn(toastService, 'markAllRead')
component.onOpenChange(false)
expect(markSpy).not.toHaveBeenCalled()
component.onOpenChange(true)
expect(markSpy).toHaveBeenCalled()
})
it('should tick the clock only while open', () => {
jest.useFakeTimers()
component.onOpenChange(true)
const opened = component.now()
jest.advanceTimersByTime(30000)
const ticked = component.now()
expect(ticked).not.toBe(opened)
component.onOpenChange(false)
jest.advanceTimersByTime(60000)
expect(component.now()).toBe(ticked)
jest.useRealTimers()
})
it('should mark toasts that were unread when opened as new', () => {
toastsSubject.next([
{ id: 'a', content: 'Seen', delay: 5000, read: true },
{ id: 'b', content: 'Unseen', delay: 5000, read: false },
])
fixture.detectChanges()
component.onOpenChange(true)
expect([...component.newIds()]).toEqual(['b'])
component.onOpenChange(false)
expect(component.newIds().size).toEqual(0)
})
it('should clear the toast and close the menu when an action is used', () => {
const toast = { id: 'a', content: 'Moved', delay: 5000 }
const closeToastSpy = jest.spyOn(toastService, 'closeToast')
const closeSpy = jest.spyOn(component.dropdown, 'close')
component.onActioned(toast)
expect(closeToastSpy).toHaveBeenCalledWith(toast)
expect(closeSpy).toHaveBeenCalled()
})
}) })
@@ -1,12 +1,20 @@
import { Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { import {
Component,
DestroyRef,
ViewChild,
computed,
inject,
signal,
} from '@angular/core'
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'
import {
NgbDropdown,
NgbDropdownModule, NgbDropdownModule,
NgbProgressbarModule, NgbProgressbarModule,
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { map } from 'rxjs' import { Subscription, interval, map } from 'rxjs'
import { Toast, ToastService } from 'src/app/services/toast.service' import { Toast, ToastLevel, ToastService } from 'src/app/services/toast.service'
import { ToastComponent } from '../../common/toast/toast.component' import { ToastComponent } from '../../common/toast/toast.component'
@Component({ @Component({
@@ -22,13 +30,44 @@ import { ToastComponent } from '../../common/toast/toast.component'
}) })
export class ToastsDropdownComponent { export class ToastsDropdownComponent {
toastService = inject(ToastService) 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( readonly toasts = toSignal(
this.toastService.getToasts().pipe(map((toasts) => [...toasts])), this.toastService.getToasts().pipe(map((toasts) => [...toasts])),
{ initialValue: [] as Toast[] } { 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 { onOpenChange(open: boolean): void {
this.toastService.suppressPopupToasts = open 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,23 +1,52 @@
<li ngbDropdown class="nav-item" (openChange)="onOpenChange($event)"> <li ngbDropdown class="nav-item" display="dynamic" placement="bottom-end" [popperOptions]="popperOptions" (openChange)="onOpenChange($event)">
<button class="btn navbar-action border-0" id="chatDropdown" ngbDropdownToggle aria-label="Chat" i18n-aria-label> <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> <i-bs width="1.3em" height="1.3em" name="chatSquareDots"></i-bs>
</button> </button>
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown"> <div ngbDropdownMenu class="dropdown-menu-end shadow p-0 overflow-hidden" aria-labelledby="chatDropdown">
<div class="chat-container bg-light p-2"> <div class="d-flex align-items-center border-bottom px-3 py-2">
<div class="chat-messages font-monospace small"> <i-bs width="1em" height="1em" name="stars" class="text-primary me-2"></i-bs>
@for (message of messages(); track message) { <span class="fw-semibold small">
<div class="message d-flex flex-row small" [class.justify-content-end]="message.role === 'user'"> @if (documentId()) {
<div class="p-2 m-2" [class.bg-body]="message.role === 'user'"> <ng-container i18n>Ask about this document</ng-container>
<span class="text-break"> } @else {
{{ message.content }} <ng-container i18n>Ask about your documents</ng-container>
@if (message.isStreaming) { <span class="blinking-cursor">|</span> } }
</span> </span>
@if (message.role === 'assistant' && message.references?.length) { @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="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) { @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]"> <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-1"></i-bs><span>{{ reference.title }}</span> <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> </a>
} }
</div> </div>
@@ -28,22 +57,29 @@
<div #scrollAnchor></div> <div #scrollAnchor></div>
</div> </div>
<form class="chat-input"> <form class="chat-input border-top p-2">
<label class="visually-hidden" for="chatInput" i18n>Message</label> <label class="visually-hidden" for="chatInput" i18n>Message</label>
<div class="input-group"> <div class="input-group">
<input <input
#chatInput #chatInput
id="chatInput" id="chatInput"
class="form-control form-control-sm" name="chatInput" type="text" class="form-control" name="chatInput" type="text"
[placeholder]="placeholder" [placeholder]="placeholder"
[disabled]="loading()" [disabled]="loading()"
[ngModel]="input()" [ngModel]="input()"
(ngModelChange)="input.set($event)" (ngModelChange)="input.set($event)"
(keydown)="searchInputKeyDown($event)" (keydown)="searchInputKeyDown($event)"
/> />
<button class="btn btn-sm btn-secondary" type="button" (click)="sendMessage()" [disabled]="loading()">Send</button> @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> </div>
</form> </form>
</div> </div>
</div>
</li> </li>
@@ -1,17 +1,13 @@
.dropdown-menu { .dropdown-menu {
width: var(--pngx-toast-max-width); width: min(36rem, calc(100vw - 20px));
} }
.chat-messages { .chat-messages {
max-height: 350px; max-height: min(60dvh, 36rem);
overflow-y: auto; overflow-y: auto;
white-space: pre-wrap; white-space: pre-wrap;
} }
.chat-references {
font-family: var(--bs-font-sans-serif);
}
.dropdown-toggle::after { .dropdown-toggle::after {
display: none; display: none;
} }
@@ -20,23 +16,44 @@
white-space: initial; white-space: initial;
} }
@media screen and (max-width: 400px) { @media screen and (max-width: 575.98px) {
:host ::ng-deep .dropdown-menu-end { .chat-messages {
right: -3rem; max-height: calc(100dvh - 12rem);
} }
} }
.blinking-cursor { .chat-markdown.streaming ::ng-deep {
font-weight: bold; > :last-child:not(ul, ol, table)::after,
font-size: 1.2em; > :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; animation: blink 1s step-end infinite;
} }
}
@keyframes blink { @keyframes blink {
from, to { 50% {
opacity: 0; opacity: 0;
} }
50% { }
opacity: 1;
.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;
} }
} }
@@ -80,8 +80,16 @@ describe('ChatComponent', () => {
expect(component.messages()[0].content).toBe('Hello') expect(component.messages()[0].content).toBe('Hello')
expect(component.loading()).toBe(true) 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') mockStream$.next('Hi')
expect(component.messages()[1].content).toBe('H') 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') mockStream$.next('Hi there')
// advance time to process the typewriter effect // advance time to process the typewriter effect
await jest.runAllTimersAsync() await jest.runAllTimersAsync()
@@ -96,6 +104,8 @@ describe('ChatComponent', () => {
await fixture.whenStable() await fixture.whenStable()
expect(component.loading()).toBe(false) expect(component.loading()).toBe(false)
expect(component.messages()[1].isStreaming).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( expect(fixture.nativeElement.querySelector('#chatInput').disabled).toBe(
false false
) )
@@ -123,6 +133,10 @@ describe('ChatComponent', () => {
mockStream$.next( mockStream$.next(
`Hi there${CHAT_METADATA_DELIMITER}{"references":[{"id":42,"title":"Bread Recipe"}]}` `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) jest.advanceTimersByTime(1000)
fixture.detectChanges() fixture.detectChanges()
@@ -156,13 +170,96 @@ describe('ChatComponent', () => {
component.input.set('Hello') component.input.set('Hello')
component.sendMessage() component.sendMessage()
mockStream$.next('Partial')
mockStream$.error('Error') mockStream$.error('Error')
expect(component.messages()[1].content).toContain( expect(component.messages()[1].content).toBe(
'⚠️ Error receiving response.' 'Partial\n\n⚠️ Error receiving response.'
) )
expect(component.loading()).toBe(false) 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', () => { it('should enqueue typewriter chunks correctly', () => {
const message = { content: '', role: 'assistant', isStreaming: true } const message = { content: '', role: 'assistant', isStreaming: true }
component.enqueueTypewriter(null, message as any) // coverage for null component.enqueueTypewriter(null, message as any) // coverage for null
@@ -207,4 +304,33 @@ describe('ChatComponent', () => {
component.searchInputKeyDown(event) component.searchInputKeyDown(event)
expect(component.sendMessage).not.toHaveBeenCalled() 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,12 +10,16 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NavigationEnd, Router, RouterModule } from '@angular/router' import { NavigationEnd, Router, RouterModule } from '@angular/router'
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap' import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { filter, map } from 'rxjs' import { filter, map, Subscription } from 'rxjs'
import { MarkdownPipe } from 'src/app/pipes/markdown.pipe'
import { import {
ChatMessage, ChatMessage,
ChatService, ChatService,
parseChatResponse, parseChatResponse,
} from 'src/app/services/chat.service' } from 'src/app/services/chat.service'
import { pngxPopperOptions } from 'src/app/utils/popper-options'
const SLOW_RESPONSE_DELAY_MS = 8000
@Component({ @Component({
selector: 'pngx-chat', selector: 'pngx-chat',
@@ -25,6 +29,7 @@ import {
RouterModule, RouterModule,
NgxBootstrapIconsModule, NgxBootstrapIconsModule,
NgbDropdownModule, NgbDropdownModule,
MarkdownPipe,
], ],
templateUrl: './chat.component.html', templateUrl: './chat.component.html',
styleUrl: './chat.component.scss', styleUrl: './chat.component.scss',
@@ -34,6 +39,8 @@ export class ChatComponent implements OnInit {
readonly loading = signal(false) readonly loading = signal(false)
readonly input = signal('') readonly input = signal('')
readonly documentId = signal<number>(undefined) readonly documentId = signal<number>(undefined)
readonly slowResponse = signal(false)
public popperOptions = pngxPopperOptions
private chatService: ChatService = inject(ChatService) private chatService: ChatService = inject(ChatService)
private router: Router = inject(Router) private router: Router = inject(Router)
@@ -43,6 +50,11 @@ export class ChatComponent implements OnInit {
private typewriterBuffer: string[] = [] private typewriterBuffer: string[] = []
private typewriterActive = false 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 { public get placeholder(): string {
return this.documentId() return this.documentId()
@@ -81,11 +93,20 @@ export class ChatComponent implements OnInit {
} }
this.messages.update((messages) => [...messages, assistantMessage]) this.messages.update((messages) => [...messages, assistantMessage])
this.loading.set(true) this.loading.set(true)
this.streamingMessage = assistantMessage
this.streamFinished = false
this.slowResponseTimeout = setTimeout(
() => this.slowResponse.set(true),
SLOW_RESPONSE_DELAY_MS
)
let lastVisibleContent = '' let lastVisibleContent = ''
this.chatService.streamChat(this.documentId(), this.input()).subscribe({ this.streamSubscription = this.chatService
.streamChat(this.documentId(), this.input())
.subscribe({
next: (chunk) => { next: (chunk) => {
this.clearSlowResponse()
const nextResponse = parseChatResponse(chunk) const nextResponse = parseChatResponse(chunk)
if (nextResponse.content.length < lastVisibleContent.length) { if (nextResponse.content.length < lastVisibleContent.length) {
@@ -103,25 +124,65 @@ export class ChatComponent implements OnInit {
this.notifyMessagesChanged() this.notifyMessagesChanged()
}, },
error: () => { error: () => {
this.flushTypewriter(assistantMessage)
assistantMessage.content += '\n\n⚠️ Error receiving response.' assistantMessage.content += '\n\n⚠️ Error receiving response.'
assistantMessage.isStreaming = false this.finishStreaming(assistantMessage)
this.notifyMessagesChanged()
this.loading.set(false)
}, },
complete: () => { complete: () => {
assistantMessage.isStreaming = false // let the typewriter catch up before hiding the cursor
this.notifyMessagesChanged() this.streamFinished = true
this.loading.set(false) if (!this.typewriterActive) this.finishStreaming(assistantMessage)
this.scrollToBottom()
}, },
}) })
this.input.set('') this.input.set('')
} }
private resetTypewriter(message: ChatMessage, content: string): void { 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)
this.typewriterBuffer = [] this.typewriterBuffer = []
this.typewriterActive = false this.typewriterActive = false
}
private resetTypewriter(message: ChatMessage, content: string): void {
this.cancelTypewriter()
message.content = content message.content = content
this.notifyMessagesChanged() this.notifyMessagesChanged()
this.scrollToBottom() this.scrollToBottom()
@@ -141,6 +202,7 @@ export class ChatComponent implements OnInit {
playTypewriter(message: ChatMessage): void { playTypewriter(message: ChatMessage): void {
if (this.typewriterBuffer.length === 0) { if (this.typewriterBuffer.length === 0) {
this.typewriterActive = false this.typewriterActive = false
if (this.streamFinished) this.finishStreaming(message)
return return
} }
@@ -149,7 +211,8 @@ export class ChatComponent implements OnInit {
this.notifyMessagesChanged() this.notifyMessagesChanged()
this.scrollToBottom() this.scrollToBottom()
setTimeout(() => this.playTypewriter(message), 10) // 10ms per character // 10ms per character
this.typewriterTimeout = setTimeout(() => this.playTypewriter(message), 10)
} }
private notifyMessagesChanged(): void { private notifyMessagesChanged(): void {
@@ -110,7 +110,7 @@ describe('CustomFieldsDropdownComponent', () => {
let modal: NgbModalRef let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1])) modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const getFieldsSpy = jest.spyOn( const getFieldsSpy = jest.spyOn(
CustomFieldsDropdownComponent.prototype as any, CustomFieldsDropdownComponent.prototype as any,
'getFields' 'getFields'
@@ -129,7 +129,9 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio
modal.componentInstance.succeeded modal.componentInstance.succeeded
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((newField) => { .subscribe((newField) => {
this.toastService.showInfo($localize`Saved field "${newField.name}".`) this.toastService.showSuccess(
$localize`Saved field "${newField.name}".`
)
this.customFieldsService.clearCache() this.customFieldsService.clearCache()
this.getFields() this.getFields()
this.created.emit(newField) this.created.emit(newField)
@@ -151,7 +151,7 @@ describe('UserEditDialogComponent', () => {
'deactivateTotp' 'deactivateTotp'
) )
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
deactivateSpy.mockReturnValueOnce(throwError(() => new Error('error'))) deactivateSpy.mockReturnValueOnce(throwError(() => new Error('error')))
component.deactivateTotp() component.deactivateTotp()
expect(deactivateSpy).toHaveBeenCalled() expect(deactivateSpy).toHaveBeenCalled()
@@ -132,7 +132,7 @@ export class UserEditDialogComponent
next: (result) => { next: (result) => {
this.totpLoading.set(false) this.totpLoading.set(false)
if (result) { if (result) {
this.toastService.showInfo($localize`Totp deactivated`) this.toastService.showSuccess($localize`Totp deactivated`)
this.object.is_mfa_enabled = false this.object.is_mfa_enabled = false
} else { } else {
this.toastService.showError($localize`Totp deactivation failed`) this.toastService.showError($localize`Totp deactivation failed`)
@@ -209,6 +209,7 @@
@if (isTagsFilter(filter.get('type').value)) { @if (isTagsFilter(filter.get('type').value)) {
<pngx-input-tags <pngx-input-tags
[allowCreate]="false" [allowCreate]="false"
[preserveHierarchy]="false"
[title]="null" [title]="null"
formControlName="values" formControlName="values"
></pngx-input-tags> ></pngx-input-tags>
@@ -320,7 +321,7 @@
<h6 class="form-label" i18n>Remove tags</h6> <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> <pngx-input-switch i18n-title title="Remove all" [horizontal]="true" formControlName="remove_all_tags"></pngx-input-switch>
<div class="mt-n3"> <div class="mt-n3">
<pngx-input-tags [allowCreate]="false" title="" formControlName="remove_tags"></pngx-input-tags> <pngx-input-tags [allowCreate]="false" [preserveHierarchy]="false" title="" formControlName="remove_tags"></pngx-input-tags>
</div> </div>
<h6 class="form-label" i18n>Remove correspondents</h6> <h6 class="form-label" i18n>Remove correspondents</h6>
@@ -52,7 +52,7 @@ describe('EmailDocumentDialogComponent', () => {
it('should support sending single document via email, showing error if needed', () => { it('should support sending single document via email, showing error if needed', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastSuccessSpy = jest.spyOn(toastService, 'showInfo') const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
component.documentIds.set([1]) component.documentIds.set([1])
component.emailAddress = 'hello@paperless-ngx.com' component.emailAddress = 'hello@paperless-ngx.com'
component.emailSubject = 'Hello' component.emailSubject = 'Hello'
@@ -73,7 +73,7 @@ describe('EmailDocumentDialogComponent', () => {
it('should support sending multiple documents via email, showing appropriate messages', () => { it('should support sending multiple documents via email, showing appropriate messages', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastSuccessSpy = jest.spyOn(toastService, 'showInfo') const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
component.documentIds.set([1, 2, 3]) component.documentIds.set([1, 2, 3])
component.emailAddress = 'hello@paperless-ngx.com' component.emailAddress = 'hello@paperless-ngx.com'
component.emailSubject = 'Hello' component.emailSubject = 'Hello'
@@ -50,7 +50,7 @@ export class EmailDocumentDialogComponent extends LoadingComponentWithPermission
this.emailSubject = '' this.emailSubject = ''
this.emailMessage = '' this.emailMessage = ''
this.close() this.close()
this.toastService.showInfo($localize`Email sent`) this.toastService.showSuccess($localize`Email sent`)
}, },
error: (e) => { error: (e) => {
this.loading.set(false) this.loading.set(false)
@@ -1,5 +1,7 @@
@if (!accordion) { @if (!accordion) {
<h5 i18n>Permissions</h5> @if (title?.length > 0) {
<h5>{{title}}</h5>
}
<ng-container [ngTemplateOutlet]="permissionsForm"></ng-container> <ng-container [ngTemplateOutlet]="permissionsForm"></ng-container>
} }
@if (accordion) { @if (accordion) {
@@ -59,6 +59,9 @@ export class PermissionsFormComponent
@Input() @Input()
accordion: boolean = false accordion: boolean = false
@Input()
title: string = $localize`Permissions`
form = new FormGroup({ form = new FormGroup({
owner: new FormControl(null), owner: new FormControl(null),
set_permissions: new FormGroup({ set_permissions: new FormGroup({
@@ -222,6 +222,22 @@ describe('TagsComponent', () => {
expect(propagated).toEqual([2, 1]) 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', () => { it('should not duplicate parents when adding sibling nested tags', () => {
const root: Tag = { id: 1, name: 'root' } const root: Tag = { id: 1, name: 'root' }
const parent: Tag = { id: 2, name: 'parent', parent: 1 } const parent: Tag = { id: 2, name: 'parent', parent: 1 }
@@ -109,6 +109,9 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
@Input() @Input()
multiple: boolean = true multiple: boolean = true
@Input()
preserveHierarchy: boolean = true
@Output() @Output()
filterDocuments = new EventEmitter<Tag[]>() filterDocuments = new EventEmitter<Tag[]>()
@@ -148,7 +151,9 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
oldValue.splice(index, 1) oldValue.splice(index, 1)
// remove children // remove children
if (this.preserveHierarchy) {
oldValue = this.removeChildren(oldValue, tag) oldValue = this.removeChildren(oldValue, tag)
}
this.value = [...oldValue] this.value = [...oldValue]
this.onChange(this.value) this.onChange(this.value)
@@ -167,7 +172,7 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
} }
public onAdd(tag: Tag) { public onAdd(tag: Tag) {
if (tag?.parent) { if (this.preserveHierarchy && tag?.parent) {
// add all parents recursively // add all parents recursively
const parent = this.getTag(tag.parent) const parent = this.getTag(tag.parent)
if (parent && !this.value.includes(parent.id)) { 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"> <h3 class="d-flex align-items-center mb-2 mb-md-1" style="line-height: 1.4">
<span class="text-truncate">{{title()}}</span> <span class="text-truncate">{{title()}}</span>
@if (id()) { @if (id()) {
<span class="badge bg-primary text-primary-text-contrast ms-3 small fs-normal cursor-pointer" (click)="copyID()"> <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>
@if (copied()) { @if (copied()) {
<i-bs width="1em" height="1em" name="clipboard-check" class="me-1"></i-bs><ng-container i18n>Copied!</ng-container> <i-bs width="1em" height="1em" name="clipboard-check" class="me-1"></i-bs><ng-container i18n>Copied!</ng-container>
} @else { } @else {
ID: {{id()}} ID: {{id()}}
} }
</span> </button>
} }
@if (subTitle()) { @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> <span class="page-subtitle h6 mb-0 mt-1 fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle()}}</span>
@@ -43,6 +43,13 @@ describe('PageHeaderComponent', () => {
expect(titleSpy).toHaveBeenCalledWith(`Foo Bar - ${environment.appTitle}`) 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', () => { it('should copy id to clipboard, reset after 3 seconds', () => {
jest.useFakeTimers() jest.useFakeTimers()
fixture.componentRef.setInput('id', 42) fixture.componentRef.setInput('id', 42)
@@ -27,7 +27,10 @@ export class PageHeaderComponent {
constructor() { constructor() {
effect(() => { effect(() => {
this.titleService.setTitle(`${this.title()} - ${environment.appTitle}`) const title = this.title()
this.titleService.setTitle(
title ? `${title} - ${environment.appTitle}` : environment.appTitle
)
}) })
} }
@@ -100,7 +100,7 @@ describe('ProfileEditDialogComponent', () => {
expect(errorSpy).toHaveBeenCalled() expect(errorSpy).toHaveBeenCalled()
updateSpy.mockClear() updateSpy.mockClear()
const infoSpy = jest.spyOn(toastService, 'showInfo') const infoSpy = jest.spyOn(toastService, 'showSuccess')
component.form.patchValue(newProfile) component.form.patchValue(newProfile)
updateSpy.mockReturnValueOnce(of(newProfile)) updateSpy.mockReturnValueOnce(of(newProfile))
component.save() component.save()
@@ -373,7 +373,7 @@ describe('ProfileEditDialogComponent', () => {
it('should activate totp', () => { it('should activate totp', () => {
const activateSpy = jest.spyOn(profileService, 'activateTotp') const activateSpy = jest.spyOn(profileService, 'activateTotp')
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const error = new Error('failed to activate totp') const error = new Error('failed to activate totp')
activateSpy.mockReturnValueOnce(throwError(() => error)) activateSpy.mockReturnValueOnce(throwError(() => error))
component.totpSettings.set({ component.totpSettings.set({
@@ -405,7 +405,7 @@ describe('ProfileEditDialogComponent', () => {
it('should deactivate totp', () => { it('should deactivate totp', () => {
const deactivateSpy = jest.spyOn(profileService, 'deactivateTotp') const deactivateSpy = jest.spyOn(profileService, 'deactivateTotp')
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const error = new Error('failed to deactivate totp') const error = new Error('failed to deactivate totp')
deactivateSpy.mockReturnValueOnce(throwError(() => error)) deactivateSpy.mockReturnValueOnce(throwError(() => error))
component.deactivateTotp() component.deactivateTotp()
@@ -204,7 +204,7 @@ export class ProfileEditDialogComponent
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({
next: () => { next: () => {
this.toastService.showInfo($localize`Profile updated successfully`) this.toastService.showSuccess($localize`Profile updated successfully`)
if (passwordChanged) { if (passwordChanged) {
this.toastService.showInfo( this.toastService.showInfo(
$localize`Password has been changed, you will be logged out momentarily.` $localize`Password has been changed, you will be logged out momentarily.`
@@ -307,7 +307,9 @@ export class ProfileEditDialogComponent
this.recoveryCodes.set(activationResponse.recovery_codes) this.recoveryCodes.set(activationResponse.recovery_codes)
this.form.get('totp_code').enable() this.form.get('totp_code').enable()
if (activationResponse.success) { if (activationResponse.success) {
this.toastService.showInfo($localize`TOTP activated successfully`) this.toastService.showSuccess(
$localize`TOTP activated successfully`
)
} else { } else {
this.toastService.showError($localize`Error activating TOTP`) this.toastService.showError($localize`Error activating TOTP`)
} }
@@ -331,7 +333,9 @@ export class ProfileEditDialogComponent
this.isTotpEnabled.set(!success) this.isTotpEnabled.set(!success)
this.recoveryCodes.set(null) this.recoveryCodes.set(null)
if (success) { if (success) {
this.toastService.showInfo($localize`TOTP deactivated successfully`) this.toastService.showSuccess(
$localize`TOTP deactivated successfully`
)
} else { } else {
this.toastService.showError($localize`Error deactivating TOTP`) this.toastService.showError($localize`Error deactivating TOTP`)
} }
@@ -1,22 +1,33 @@
<ngb-toast <ngb-toast
[autohide]="autohide" [autohide]="false"
[delay]="toast.delay" [class.mb-2]="autohide"
[class]="toast.classname" [class.toast-list-item]="!autohide"
[class.mb-2]="true" [class.toast-new]="isNew"
[class.toast-success]="toast.level === ToastLevel.Success"
[class.toast-error]="toast.level === ToastLevel.Error"
(mouseenter)="setHovered(true)"
(mouseleave)="setHovered(false)"
(focusin)="setFocused(true)"
(focusout)="setFocused(false)"
(shown)="onShown(toast)" (shown)="onShown(toast)"
(hidden)="hidden.emit(toast)"> (hidden)="hidden.emit(toast)">
@if (autohide) { @if (autohide) {
<ngb-progressbar class="position-absolute h-100 w-100 top-90 start-0 bottom-0 end-0 pe-none" type="dark" [max]="toast.delay" [value]="toast.delayRemaining"></ngb-progressbar> <ngb-progressbar class="position-absolute w-100 start-0 bottom-0 pe-none" [max]="toast.delay" [value]="delayRemaining()"></ngb-progressbar>
<span class="visually-hidden">{{ toast.delayRemaining / 1000 | number: '1.0-0' }} seconds</span> <span class="visually-hidden">{{ delayRemaining() / 1000 | number: '1.0-0' }} seconds</span>
} }
<div class="d-flex align-items-top"> <div class="d-flex align-items-top">
@if (!toast.error) { @switch (toast.level) {
<i-bs width="0.9em" height="0.9em" name="info-circle"></i-bs> @case (ToastLevel.Success) {
<i-bs width="0.9em" height="0.9em" name="check-circle" class="toast-icon"></i-bs>
} }
@if (toast.error) { @case (ToastLevel.Error) {
<i-bs width="0.9em" height="0.9em" name="exclamation-triangle"></i-bs> <i-bs width="0.9em" height="0.9em" name="exclamation-triangle" class="toast-icon"></i-bs>
} }
<div> @default {
<i-bs width="0.9em" height="0.9em" name="info-circle" class="toast-icon"></i-bs>
}
}
<div class="toast-content">
<p class="ms-2 mb-0 text-break">{{toast.content}}</p> <p class="ms-2 mb-0 text-break">{{toast.content}}</p>
@if (toast.error) { @if (toast.error) {
<details class="ms-2"> <details class="ms-2">
@@ -48,9 +59,12 @@
</details> </details>
} }
@if (toast.action) { @if (toast.action) {
<p class="mb-0 mt-2"><button class="btn btn-sm btn-outline-secondary" (click)="closed.emit(toast); toast.action()">{{toast.actionName}}</button></p> <button class="btn btn-sm toast-action ms-2 mt-2" (click)="actioned.emit(toast); toast.action()">{{toast.actionName}}</button>
} }
</div> </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> <button type="button" class="btn-close ms-auto flex-shrink-0" data-bs-dismiss="toast" aria-label="Close" (click)="closed.emit(toast);"></button>
</div> </div>
</ngb-toast> </ngb-toast>
@@ -2,19 +2,105 @@
position: relative; position: relative;
} }
::ng-deep .toast.error { .toast {
border-color: hsla(350, 79%, 40%, 0.4); // bg-danger --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);
} }
::ng-deep .toast.error .toast-body { &.toast-error {
background-color: hsla(350, 79%, 40%, 0.8); // bg-danger --pngx-toast-accent: var(--pngx-toast-error);
border-top-left-radius: inherit; background-color: var(--pngx-toast-error-bg);
border-top-right-radius: inherit; border-color: color-mix(in srgb, var(--pngx-toast-error) 35%, transparent);
border-bottom-left-radius: inherit; }
border-bottom-right-radius: inherit;
} }
.toast-icon {
color: var(--pngx-toast-accent);
}
.toast-content {
flex: 1 1 auto;
min-width: 0;
}
.toast .btn-close {
font-size: .6rem;
margin-top: .3rem;
margin-left: .75rem !important;
opacity: .45;
}
.toast:hover .btn-close {
opacity: .85;
}
// compact rows in the notifications dropdown
.toast.toast-list-item {
width: 100%;
max-width: none;
background-color: var(--pngx-toast-row-bg);
border: 0;
box-shadow: none;
&:hover {
background-color: var(--pngx-toast-row-hover-bg);
}
&.toast-error {
background-color: var(--pngx-toast-error-bg);
}
::ng-deep .toast-body {
padding: .625rem .75rem;
}
.toast-time {
margin-top: .1rem;
}
// marks notifications that arrived since the dropdown was last opened
&.toast-new .toast-time::before {
content: '';
display: inline-block;
width: .45rem;
height: .45rem;
margin-right: .4rem;
vertical-align: .05rem;
border-radius: 50%;
background-color: var(--pngx-toast-info);
}
}
.toast-action {
--bs-btn-padding-y: .1rem;
--bs-btn-padding-x: .6rem;
--bs-btn-font-size: .8rem;
--bs-btn-font-weight: 600;
--bs-btn-color: var(--pngx-toast-accent);
--bs-btn-bg: color-mix(in srgb, var(--pngx-toast-accent) 14%, transparent);
--bs-btn-border-color: transparent;
--bs-btn-hover-color: var(--pngx-toast-accent);
--bs-btn-hover-bg: color-mix(in srgb, var(--pngx-toast-accent) 24%, transparent);
--bs-btn-hover-border-color: transparent;
--bs-btn-active-color: var(--pngx-toast-accent);
--bs-btn-active-bg: color-mix(in srgb, var(--pngx-toast-accent) 30%, transparent);
--bs-btn-active-border-color: transparent;
}
// countdown thin line
.progress { .progress {
background-color: var(--pngx-primary); height: 2px;
opacity: .07; background-color: transparent;
::ng-deep .progress-bar {
background-color: var(--bs-secondary-color);
opacity: .5;
}
} }
@@ -1,7 +1,11 @@
import { DatePipe } from '@angular/common'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing' import { ComponentFixture, TestBed } from '@angular/core/testing'
import { Clipboard } from '@angular/cdk/clipboard' import { Clipboard } from '@angular/cdk/clipboard'
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { ToastLevel } from 'src/app/services/toast.service'
import { ToastComponent } from './toast.component' import { ToastComponent } from './toast.component'
const toast1 = { const toast1 = {
@@ -30,6 +34,11 @@ describe('ToastComponent', () => {
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [ToastComponent, NgxBootstrapIconsModule.pick(allIcons)], imports: [ToastComponent, NgxBootstrapIconsModule.pick(allIcons)],
providers: [
DatePipe,
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
}).compileComponents() }).compileComponents()
fixture = TestBed.createComponent(ToastComponent) fixture = TestBed.createComponent(ToastComponent)
@@ -46,11 +55,117 @@ describe('ToastComponent', () => {
component.toast = toast2 component.toast = toast2
fixture.detectChanges() fixture.detectChanges()
component.onShown(toast2) component.onShown(toast2)
jest.advanceTimersByTime(5000) jest.advanceTimersByTime(5000 + 150) // ticks every 150ms
expect(component.toast.delayRemaining).toEqual(0) expect(component.delayRemaining()).toEqual(0)
jest.useRealTimers() jest.useRealTimers()
}) })
it('should start the progress bar full before the countdown begins', () => {
component.toast = { content: 'Info', delay: 3000 }
fixture.detectChanges()
expect(component.delayRemaining()).toEqual(3000)
})
it('should hide the toast when the countdown ends', () => {
jest.useFakeTimers()
const toast = { content: 'Info', delay: 3000 }
component.toast = toast
fixture.detectChanges()
const hideSpy = jest.spyOn(component.ngbToast, 'hide')
component.onShown(toast)
jest.advanceTimersByTime(2850)
expect(hideSpy).not.toHaveBeenCalled()
jest.advanceTimersByTime(300)
expect(hideSpy).toHaveBeenCalled()
jest.useRealTimers()
})
it('should pause the countdown while hovered', () => {
jest.useFakeTimers()
const toast = { content: 'Info', delay: 3000 }
component.toast = toast
fixture.detectChanges()
const hideSpy = jest.spyOn(component.ngbToast, 'hide')
const el: HTMLElement = fixture.nativeElement.querySelector('ngb-toast')
component.onShown(toast)
jest.advanceTimersByTime(1500)
el.dispatchEvent(new MouseEvent('mouseenter'))
const remaining = component.delayRemaining()
jest.advanceTimersByTime(10000)
expect(component.delayRemaining()).toEqual(remaining)
expect(hideSpy).not.toHaveBeenCalled()
el.dispatchEvent(new MouseEvent('mouseleave'))
jest.advanceTimersByTime(remaining + 150)
expect(hideSpy).toHaveBeenCalled()
jest.useRealTimers()
})
it('should pause the countdown while focus is inside', () => {
jest.useFakeTimers()
const toast = { content: 'Info', delay: 3000 }
component.toast = toast
fixture.detectChanges()
const el: HTMLElement = fixture.nativeElement.querySelector('ngb-toast')
component.onShown(toast)
el.dispatchEvent(new FocusEvent('focusin'))
// mouse leaving doesn't resume while focus is still inside
el.dispatchEvent(new MouseEvent('mouseenter'))
el.dispatchEvent(new MouseEvent('mouseleave'))
jest.advanceTimersByTime(5000)
expect(component.delayRemaining()).toEqual(3000)
el.dispatchEvent(new FocusEvent('focusout'))
jest.advanceTimersByTime(3000)
expect(component.delayRemaining()).toEqual(0)
jest.useRealTimers()
})
it('should not count down without autohide', () => {
jest.useFakeTimers()
const toast = { content: 'Info', delay: 3000 }
component.toast = toast
component.autohide = false
fixture.detectChanges()
component.onShown(toast)
jest.advanceTimersByTime(5000)
expect(component.delayRemaining()).toBeNull()
jest.useRealTimers()
})
it('should show an icon and colors for each level', () => {
component.toast = {
content: 'Saved',
delay: 5000,
level: ToastLevel.Success,
}
fixture.detectChanges()
const el: HTMLElement = fixture.nativeElement
expect(el.querySelector('i-bs[name="check-circle"]')).not.toBeNull()
expect(el.querySelector('ngb-toast').classList).not.toContain('toast-error')
fixture = TestBed.createComponent(ToastComponent)
fixture.componentInstance.toast = {
content: 'Failed',
delay: 5000,
level: ToastLevel.Error,
}
fixture.detectChanges()
const errorEl: HTMLElement = fixture.nativeElement
expect(
errorEl.querySelector('i-bs[name="exclamation-triangle"]')
).not.toBeNull()
expect(errorEl.querySelector('ngb-toast').classList).toContain(
'toast-error'
)
fixture = TestBed.createComponent(ToastComponent)
fixture.componentInstance.toast = { content: 'Started', delay: 5000 }
fixture.detectChanges()
expect(
fixture.nativeElement.querySelector('i-bs[name="info-circle"]')
).not.toBeNull()
})
it('should show an error if given with toast', () => { it('should show an error if given with toast', () => {
component.toast = toast1 component.toast = toast1
fixture.detectChanges() fixture.detectChanges()
@@ -88,4 +203,70 @@ describe('ToastComponent', () => {
component.getErrorText({ error: new Array(205).join('a') }) component.getErrorText({ error: new Array(205).join('a') })
).toContain('...') ).toContain('...')
}) })
it('should emit actioned, not closed, when the action is clicked', () => {
const action = jest.fn()
component.toast = {
content: 'Moved',
delay: 5000,
action,
actionName: 'Undo',
}
fixture.detectChanges()
const closedSpy = jest.spyOn(component.closed, 'emit')
const actionedSpy = jest.spyOn(component.actioned, 'emit')
const button: HTMLButtonElement = [
...fixture.nativeElement.querySelectorAll('button'),
].find((b) => b.textContent.includes('Undo'))
button.click()
expect(actionedSpy).toHaveBeenCalled()
expect(closedSpy).not.toHaveBeenCalled()
expect(action).toHaveBeenCalled()
})
it('should show a relative timestamp in the notifications list', () => {
component.toast = { content: 'Saved', delay: 5000, timestamp: new Date() }
component.autohide = false
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('Just now')
})
it('should update the relative timestamp as time passes', () => {
jest.useFakeTimers()
jest.setSystemTime(new Date('2026-10-19T12:00:00Z'))
component.toast = { content: 'Saved', delay: 5000, timestamp: new Date() }
component.autohide = false
fixture.componentRef.setInput('now', new Date())
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('Just now')
jest.setSystemTime(new Date('2026-10-19T12:03:00Z'))
fixture.componentRef.setInput('now', new Date())
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('3 minutes ago')
jest.useRealTimers()
})
it('should show a compact action link in the notifications list', () => {
const action = jest.fn()
component.toast = {
content: 'Moved',
delay: 5000,
action,
actionName: 'Undo',
timestamp: new Date(),
}
component.autohide = false
fixture.detectChanges()
const link: HTMLButtonElement =
fixture.nativeElement.querySelector('.toast-action')
expect(link.textContent).toContain('Undo')
expect(
fixture.nativeElement.querySelector('.btn-outline-secondary')
).toBeNull()
const actionedSpy = jest.spyOn(component.actioned, 'emit')
link.click()
expect(actionedSpy).toHaveBeenCalled()
expect(action).toHaveBeenCalled()
})
}) })
@@ -1,20 +1,26 @@
import { Clipboard } from '@angular/cdk/clipboard' import { Clipboard } from '@angular/cdk/clipboard'
import { DecimalPipe } from '@angular/common' import { DatePipe, DecimalPipe } from '@angular/common'
import { import {
Component, Component,
DestroyRef,
EventEmitter, EventEmitter,
Input, Input,
OnInit,
Output, Output,
ViewChild,
inject, inject,
signal, signal,
} from '@angular/core' } from '@angular/core'
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import { import {
NgbProgressbarModule, NgbProgressbarModule,
NgbToast,
NgbToastModule, NgbToastModule,
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { interval, take } from 'rxjs' import { Subscription, interval, takeWhile } from 'rxjs'
import { Toast } from 'src/app/services/toast.service' import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { Toast, ToastLevel } from 'src/app/services/toast.service'
@Component({ @Component({
selector: 'pngx-toast', selector: 'pngx-toast',
@@ -26,32 +32,79 @@ import { Toast } from 'src/app/services/toast.service'
], ],
templateUrl: './toast.component.html', templateUrl: './toast.component.html',
styleUrl: './toast.component.scss', styleUrl: './toast.component.scss',
providers: [DatePipe, CustomDatePipe],
}) })
export class ToastComponent { export class ToastComponent implements OnInit {
private clipboard = inject(Clipboard) private clipboard = inject(Clipboard)
private customDatePipe = inject(CustomDatePipe)
private destroyRef = inject(DestroyRef)
readonly copied = signal(false) 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() toast: Toast
@Input() autohide: boolean = true @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() hidden: EventEmitter<Toast> = new EventEmitter<Toast>()
@Output() closed: 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) { onShown(toast: Toast) {
if (!this.autohide) return if (!this.autohide) return
const refreshInterval = 150 const refreshInterval = 150
const delay = toast.delay - 500 // for fade animation this.delayRemaining.set(toast.delay)
interval(refreshInterval) this.countdown?.unsubscribe()
.pipe(take(Math.round(delay / refreshInterval))) this.countdown = interval(refreshInterval)
.subscribe((count) => { .pipe(
toast.delayRemaining = Math.max( takeWhile(() => this.delayRemaining() > 0),
0, takeUntilDestroyed(this.destroyRef)
delay - refreshInterval * (count + 1)
) )
.subscribe(() => {
if (this.paused) return
this.delayRemaining.update((remaining) =>
Math.max(0, remaining - refreshInterval)
)
if (this.delayRemaining() === 0) {
this.ngbToast?.hide()
}
}) })
} }
@@ -1,3 +1,3 @@
@for (toast of toasts(); track toast.id) { @for (toast of toasts(); track toast.id) {
<pngx-toast [toast]="toast" [autohide]="true" (closed)="closeToast()"></pngx-toast> <pngx-toast [toast]="toast" [autohide]="true" (closed)="removePopup(toast)" (actioned)="closeToast(toast)" (hidden)="removePopup(toast)"></pngx-toast>
} }
@@ -1,10 +1,11 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing' import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing' import { ComponentFixture, TestBed } from '@angular/core/testing'
import { By } from '@angular/platform-browser'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { Subject } from 'rxjs' import { Subject } from 'rxjs'
import { Toast, ToastService } from 'src/app/services/toast.service' import { Toast, ToastService } from 'src/app/services/toast.service'
import { ToastsComponent } from './toasts.component' import { MAX_VISIBLE_TOASTS, ToastsComponent } from './toasts.component'
const toast = { const toast = {
content: 'Error 2 content', content: 'Error 2 content',
@@ -50,7 +51,7 @@ describe('ToastsComponent', () => {
it('should close toast', () => { it('should close toast', () => {
toastSubject.next(toast) toastSubject.next(toast)
const closeToastSpy = jest.spyOn(toastService, 'closeToast') const closeToastSpy = jest.spyOn(toastService, 'closeToast')
component.closeToast() component.closeToast(toast)
expect(component.toasts()).toEqual([]) expect(component.toasts()).toEqual([])
expect(closeToastSpy).toHaveBeenCalledWith(toast) expect(closeToastSpy).toHaveBeenCalledWith(toast)
}) })
@@ -59,4 +60,59 @@ describe('ToastsComponent', () => {
toastSubject.next(toast) toastSubject.next(toast)
expect(component.toasts()).toEqual([toast]) expect(component.toasts()).toEqual([toast])
}) })
it('should stack popups newest first, up to the maximum', () => {
const toasts: Toast[] = Array.from(
{ length: MAX_VISIBLE_TOASTS + 1 },
(_, i) => ({ id: `${i}`, content: `Toast ${i}`, delay: 5000 })
)
toasts.forEach((t) => toastSubject.next(t))
expect(component.toasts().map((t) => t.id)).toEqual(['3', '2', '1'])
// showing a visible toast again moves it to the top without duplicating
toastSubject.next(toasts[1])
expect(component.toasts().map((t) => t.id)).toEqual(['1', '3', '2'])
})
it('should remove a popup when it hides without closing it', () => {
const closeToastSpy = jest.spyOn(toastService, 'closeToast')
const first = { id: 'a', content: 'A', delay: 5000 }
const second = { id: 'b', content: 'B', delay: 5000 }
toastSubject.next(first)
toastSubject.next(second)
component.removePopup(first)
expect(component.toasts()).toEqual([second])
expect(closeToastSpy).not.toHaveBeenCalled()
})
it('should clear popups when toastService emits null', () => {
toastSubject.next(toast)
toastSubject.next(null)
expect(component.toasts()).toEqual([])
})
it('should render one toast component per popup', () => {
toastSubject.next({ id: 'a', content: 'A', delay: 5000 })
toastSubject.next({ id: 'b', content: 'B', delay: 5000 })
fixture.detectChanges()
expect(fixture.nativeElement.querySelectorAll('pngx-toast')).toHaveLength(2)
})
it('should only hide the popup on close, but clear it on action', () => {
const closeToastSpy = jest.spyOn(toastService, 'closeToast')
const first = { id: 'a', content: 'A', delay: 5000 }
const second = { id: 'b', content: 'B', delay: 5000 }
toastSubject.next(first)
toastSubject.next(second)
fixture.detectChanges()
const popups = fixture.debugElement.queryAll(By.css('pngx-toast'))
popups[1].triggerEventHandler('closed', first)
expect(closeToastSpy).not.toHaveBeenCalled()
expect(component.toasts()).toEqual([second])
popups[0].triggerEventHandler('actioned', second)
expect(closeToastSpy).toHaveBeenCalledWith(second)
expect(component.toasts()).toEqual([])
})
}) })
@@ -1,14 +1,18 @@
import { Component, inject } from '@angular/core' import { Component, inject, signal } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop' import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import { import {
NgbAccordionModule, NgbAccordionModule,
NgbProgressbarModule, NgbProgressbarModule,
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { map, merge, Subject } from 'rxjs'
import { Toast, ToastService } from 'src/app/services/toast.service' import { Toast, ToastService } from 'src/app/services/toast.service'
import { ToastComponent } from '../toast/toast.component' import { ToastComponent } from '../toast/toast.component'
/**
* Maximum number of popup toasts shown at once
*/
export const MAX_VISIBLE_TOASTS = 3
@Component({ @Component({
selector: 'pngx-toasts', selector: 'pngx-toasts',
templateUrl: './toasts.component.html', templateUrl: './toasts.component.html',
@@ -23,18 +27,31 @@ import { ToastComponent } from '../toast/toast.component'
export class ToastsComponent { export class ToastsComponent {
toastService = inject(ToastService) toastService = inject(ToastService)
private readonly closedToast = new Subject<void>() readonly toasts = signal<Toast[]>([])
readonly toasts = toSignal( constructor() {
merge( this.toastService.showToast
this.toastService.showToast.pipe(map((toast) => (toast ? [toast] : []))), .pipe(takeUntilDestroyed())
this.closedToast.pipe(map(() => [] as Toast[])) .subscribe((toast) => {
), if (!toast) {
{ initialValue: [] as Toast[] } this.toasts.set([])
return
}
this.toasts.update((toasts) =>
[toast, ...toasts.filter((t) => t.id !== toast.id)].slice(
0,
MAX_VISIBLE_TOASTS
) )
)
})
}
closeToast() { closeToast(toast: Toast) {
this.toastService.closeToast(this.toasts()[0]) this.toastService.closeToast(toast)
this.closedToast.next() this.removePopup(toast)
}
removePopup(toast: Toast) {
this.toasts.update((toasts) => toasts.filter((t) => t.id !== toast.id))
} }
} }
@@ -0,0 +1,52 @@
@if (canAddDocuments && getStatus().length > 0) {
<div class="fixed-bottom p-2 p-md-4 d-flex justify-content-end pe-none consumer-status-list" [ngClass]="slimSidebarEnabled ? 'col-slim' : 'offset-sidebar-expanded'">
<div class="col col-lg-4 col-xl-3 ps-0 pe-0 ps-lg-3 pe-lg-0 pe-auto overflow-y-auto">
<div class="card shadow-sm consumer-status-card">
<div class="card-body">
<div class="d-flex flex-wrap align-items-center column-gap-2">
<p class="m-0 small text-muted">{{getStatusSummary()}}</p>
<div class="d-flex align-items-center gap-2 ms-auto">
@if (getStatusCompleted().length > 0) {
<a class="btn-link small text-nowrap" (click)="dismissCompleted()" [routerLink]="[]">
<span class="me-1" i18n="This button dismisses all status messages about processed documents in the tray (failed and successful)">Dismiss completed</span>
<i-bs name="check2-all"></i-bs>
</a>
}
<button type="button" class="btn btn-sm btn-link p-0 text-muted" (click)="toggleCollapsed()"
[attr.aria-expanded]="!collapsed()" aria-label="Toggle document processing status" i18n-aria-label>
<i-bs [name]="collapsed() ? 'chevron-up' : 'chevron-down'"></i-bs>
</button>
</div>
</div>
@if (!collapsed()) {
@for (status of getStatus(); track status) {
<ng-container [ngTemplateOutlet]="consumerAlert" [ngTemplateOutletContext]="{ $implicit: status }"></ng-container>
}
}
</div>
</div>
</div>
</div>
}
<ng-template #consumerAlert let-status>
<ngb-alert type="secondary" class="mt-2 mb-0" [dismissible]="isFinished(status)" (closed)="dismiss(status)">
<h6 class="alert-heading">{{status.filename}}</h6>
@if (!isFinished(status) || (isFinished(status) && !status.documentId)) {
<p class="mb-0 pb-1">{{status.message}}</p>
}
<ngb-progressbar [value]="status.getProgress()" [max]="1" [type]="getStatusColor(status)"></ngb-progressbar>
<div *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
@if (isFinished(status)) {
<div>
@if (status.documentId) {
<a class="btn btn-sm btn-outline-primary btn-open" [routerLink]="['/documents', status.documentId]" (click)="dismiss(status)">
<small i18n>Open document</small>
<i-bs name="arrow-right-short"></i-bs>
</a>
}
</div>
}
</div>
</ngb-alert>
</ng-template>
@@ -0,0 +1,36 @@
:host ::ng-deep i-bs svg {
margin-top: -3px;
}
.alert-heading {
font-size: 80%;
font-weight: bold;
}
.btn-open {
line-height: 1;
i-bs {
margin-top: -1px;
}
}
.consumer-status-card {
background-color: rgba(var(--bs-body-bg-rgb), .95) !important;
}
.consumer-status-list {
max-height: 60vh;
z-index: 1030;
}
.progress {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
height: auto;
mix-blend-mode: soft-light;
pointer-events: none;
}
@@ -0,0 +1,206 @@
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
})
}
@@ -0,0 +1,165 @@
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', () => { it('should update saved view sorting on drag + drop, show info', () => {
const settingsSpy = jest.spyOn(settingsService, 'updateDashboardViewsSort') const settingsSpy = jest.spyOn(settingsService, 'updateDashboardViewsSort')
const toastSpy = jest.spyOn(toastService, 'showInfo') const toastSpy = jest.spyOn(toastService, 'showSuccess')
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true)) jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
component.onDrop({ previousIndex: 0, currentIndex: 1 } as CdkDragDrop< component.onDrop({ previousIndex: 0, currentIndex: 1 } as CdkDragDrop<
SavedView[] SavedView[]
@@ -87,7 +87,7 @@ export class DashboardComponent extends ComponentWithPermissions {
.updateDashboardViewsSort(this.dashboardViews()) .updateDashboardViewsSort(this.dashboardViews())
.subscribe({ .subscribe({
next: () => { next: () => {
this.toastService.showInfo($localize`Dashboard updated`) this.toastService.showSuccess($localize`Dashboard updated`)
}, },
error: (e) => { error: (e) => {
this.toastService.showError($localize`Error updating dashboard`, e) this.toastService.showError($localize`Error updating dashboard`, e)
@@ -8,59 +8,5 @@
</button> </button>
<input type="file" class="visually-hidden" (change)="onFileSelected($event)" multiple #fileUpload> <input type="file" class="visually-hidden" (change)="onFileSelected($event)" multiple #fileUpload>
</form> </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> </div>
</pngx-widget-frame> </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>
@@ -20,48 +20,6 @@
font-size: 0.75rem; 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 { ::ng-deep .ngx-file-drop__drop-zone--over {
background-color: var(--pngx-primary-faded) !important; 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
}
}
@@ -9,35 +9,11 @@ import { routes } from 'src/app/app-routing.module'
import { PermissionsGuard } from 'src/app/guards/permissions.guard' import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { PermissionsService } from 'src/app/services/permissions.service' import { PermissionsService } from 'src/app/services/permissions.service'
import { UploadDocumentsService } from 'src/app/services/upload-documents.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' 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', () => { describe('UploadFileWidgetComponent', () => {
let component: UploadFileWidgetComponent let component: UploadFileWidgetComponent
let fixture: ComponentFixture<UploadFileWidgetComponent> let fixture: ComponentFixture<UploadFileWidgetComponent>
let websocketStatusService: WebsocketStatusService
let uploadDocumentsService: UploadDocumentsService let uploadDocumentsService: UploadDocumentsService
beforeEach(async () => { beforeEach(async () => {
@@ -61,7 +37,6 @@ describe('UploadFileWidgetComponent', () => {
], ],
}).compileComponents() }).compileComponents()
websocketStatusService = TestBed.inject(WebsocketStatusService)
uploadDocumentsService = TestBed.inject(UploadDocumentsService) uploadDocumentsService = TestBed.inject(UploadDocumentsService)
fixture = TestBed.createComponent(UploadFileWidgetComponent) fixture = TestBed.createComponent(UploadFileWidgetComponent)
component = fixture.componentInstance component = fixture.componentInstance
@@ -85,93 +60,10 @@ describe('UploadFileWidgetComponent', () => {
'file.pdf' 'file.pdf'
) )
const fileInput = fixture.debugElement.query(By.css('input')) const fileInput = fixture.debugElement.query(By.css('input'))
jest.spyOn(fileInput.nativeElement, 'files', 'get').mockReturnValue({ jest
item: () => file, .spyOn(fileInput.nativeElement, 'files', 'get')
length: 1, .mockReturnValue([file] as any)
[Symbol.iterator]: () => ({
next: () => ({ done: false, value: file }),
}),
} as any)
fileInput.nativeElement.dispatchEvent(new Event('change')) fileInput.nativeElement.dispatchEvent(new Event('change'))
expect(uploadSpy).toHaveBeenCalled() expect(uploadSpy).toHaveBeenCalledWith(file)
})
it('should generate stats summary', () => {
mockConsumerStatuses(websocketStatusService)
expect(component.getStatusSummary()).toEqual(
'Processing: 6, Failed: 1, Added: 4'
)
})
it('should report an upload progress summary', () => {
mockConsumerStatuses(websocketStatusService)
expect(component.getTotalUploadProgress()).toEqual(0.75)
})
it('should change color by status phase', () => {
const processingStatus = new FileStatus()
processingStatus.phase = FileStatusPhase.WORKING
expect(component.getStatusColor(processingStatus)).toEqual('primary')
processingStatus.phase = FileStatusPhase.UPLOADING
expect(component.getStatusColor(processingStatus)).toEqual('primary')
const failedStatus = new FileStatus()
failedStatus.phase = FileStatusPhase.FAILED
expect(component.getStatusColor(failedStatus)).toEqual('danger')
const successStatus = new FileStatus()
successStatus.phase = FileStatusPhase.SUCCESS
expect(component.getStatusColor(successStatus)).toEqual('success')
})
it('should allow dismissing an alert', () => {
const dismissSpy = jest.spyOn(websocketStatusService, 'dismiss')
component.dismiss(new FileStatus())
expect(dismissSpy).toHaveBeenCalled()
})
it('should allow dismissing completed alerts', () => {
jest.useFakeTimers()
mockConsumerStatuses(websocketStatusService)
fixture.detectChanges()
jest
.spyOn(component, 'getStatusCompleted')
.mockImplementation(() => SUCCESS_STATUSES)
const dismissSpy = jest.spyOn(websocketStatusService, 'dismiss')
component.dismissCompleted()
jest.advanceTimersByTime(1000)
fixture.detectChanges()
expect(dismissSpy).toHaveBeenCalledTimes(4)
jest.useRealTimers()
}) })
}) })
function mockConsumerStatuses(consumerStatusService) {
const partialUpload1 = new FileStatus()
partialUpload1.currentPhaseProgress = 50
partialUpload1.currentPhaseMaxProgress = 50
const partialUpload2 = new FileStatus()
partialUpload2.currentPhaseProgress = 25
partialUpload2.currentPhaseMaxProgress = 50
jest
.spyOn(consumerStatusService, 'getConsumerStatus')
.mockImplementation((phase) => {
switch (phase) {
case FileStatusPhase.FAILED:
return FAILED_STATUSES
case FileStatusPhase.WORKING:
return WORKING_STATUSES
case FileStatusPhase.STARTED:
return STARTED_STATUSES
case FileStatusPhase.SUCCESS:
return SUCCESS_STATUSES
case FileStatusPhase.UPLOADING:
return [partialUpload1, partialUpload2]
default:
return DEFAULT_STATUSES
}
})
jest
.spyOn(consumerStatusService, 'getConsumerStatusNotCompleted')
.mockImplementation(() => {
return DEFAULT_STATUSES
})
}
@@ -1,23 +1,9 @@
import { NgClass, NgTemplateOutlet } from '@angular/common' import { Component, inject } from '@angular/core'
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 { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap' import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component' 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 { 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 { 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' import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
@Component({ @Component({
@@ -27,117 +13,16 @@ import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
imports: [ imports: [
WidgetFrameComponent, WidgetFrameComponent,
IfPermissionsDirective, IfPermissionsDirective,
NgClass,
NgTemplateOutlet,
RouterModule,
NgbAlertModule,
NgbProgressbarModule,
NgxBootstrapIconsModule, NgxBootstrapIconsModule,
TourNgBootstrap, TourNgBootstrap,
], ],
}) })
export class UploadFileWidgetComponent extends ComponentWithPermissions { export class UploadFileWidgetComponent extends ComponentWithPermissions {
private websocketStatusService = inject(WebsocketStatusService)
private uploadDocumentsService = inject(UploadDocumentsService) 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) { public onFileSelected(event: Event) {
const files = (event.target as HTMLInputElement).files const input = event.target as HTMLInputElement
for (let i = 0; i < files?.length; i++) { this.uploadDocumentsService.uploadFiles(Array.from(input.files ?? []))
const file = files.item(i) input.value = ''
file && this.uploadDocumentsService.uploadFile(file)
}
}
get slimSidebarEnabled(): boolean {
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
} }
} }
@@ -1,25 +1,4 @@
<pngx-page-header [title]="title()" [id]="documentId()"> <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> <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> <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> </button>
@@ -34,7 +13,7 @@
(versionsUpdated)="onVersionsUpdated($event)" (versionsUpdated)="onVersionsUpdated($event)"
/> />
<div class="btn-group"> <div class="btn-group ms-4">
<button (click)="download()" class="btn btn-sm btn-outline-primary" [disabled]="downloading()" aria-label="Download" i18n-aria-label> <button (click)="download()" class="btn btn-sm btn-outline-primary" [disabled]="downloading()" aria-label="Download" i18n-aria-label>
@if (downloading()) { @if (downloading()) {
<div class="spinner-border spinner-border-sm" role="status"></div> <div class="spinner-border spinner-border-sm" role="status"></div>
@@ -94,7 +73,7 @@
<button class="btn btn-sm btn-outline-primary" id="sendDropdown" ngbDropdownToggle aria-label="Send" i18n-aria-label> <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> <i-bs name="send"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Send</ng-container></div>
</button> </button>
<div ngbDropdownMenu aria-labelledby="actionsDropdown" class="shadow"> <div ngbDropdownMenu aria-labelledby="sendDropdown" class="shadow">
<button ngbDropdownItem (click)="openShareLinks()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.ShareLink }"> <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> <i-bs name="link" class="me-1"></i-bs><span i18n>Share Links</span>
</button> </button>
@@ -113,8 +92,8 @@
<form [formGroup]='documentForm' (ngSubmit)="save()"> <form [formGroup]='documentForm' (ngSubmit)="save()">
<div class="btn-toolbar justify-content-end mb-1 pb-3 gap-2 row-gap-2 border-bottom"> <div class="btn-toolbar mb-1 pb-3 gap-2 row-gap-2 border-bottom">
<div class="btn-group me-auto"> <div class="btn-group">
<button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Close" (click)="close()"> <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> <i-bs width="1.2em" height="1.2em" name="x"></i-bs>
</button> </button>
@@ -127,8 +106,7 @@
</div> </div>
<ng-container *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }"> <ng-container *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
<div class="d-flex gap-2"> <div class="btn-group ms-3">
<div class="btn-group">
<pngx-suggestions-dropdown *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }" <pngx-suggestions-dropdown *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }"
[disabled]="!userCanEdit || suggestionsLoading()" [disabled]="!userCanEdit || suggestionsLoading()"
[loading]="suggestionsLoading()" [loading]="suggestionsLoading()"
@@ -151,19 +129,7 @@
</pngx-suggestions-dropdown> </pngx-suggestions-dropdown>
</div> </div>
<div class="btn-group"> <div class="ms-auto">
<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="ps-3">
<ng-container *ngTemplateOutlet="saveButtons"></ng-container> <ng-container *ngTemplateOutlet="saveButtons"></ng-container>
</div> </div>
</ng-container> </ng-container>
@@ -290,9 +256,21 @@
} }
</div> </div>
<div class="d-flex justify-content-end border-top pt-3"> <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> <ng-container *ngTemplateOutlet="saveButtons"></ng-container>
</div> </div>
</div>
</ng-template> </ng-template>
</li> </li>
@@ -328,9 +306,9 @@
<td i18n>Original filename</td> <td i18n>Original filename</td>
<td>{{metadata()?.original_filename}}</td> <td>{{metadata()?.original_filename}}</td>
</tr> </tr>
<tr> <tr class="align-middle">
<td i18n>Original SHA256 checksum</td> <td i18n>Original SHA256 checksum</td>
<td>{{metadata()?.original_checksum}}</td> <td><span class="badge bg-light font-monospace text-wrap text-break text-start">{{metadata()?.original_checksum}}</span></td>
</tr> </tr>
<tr> <tr>
<td i18n>Original file size</td> <td i18n>Original file size</td>
@@ -341,9 +319,9 @@
<td>{{metadata()?.original_mime_type}}</td> <td>{{metadata()?.original_mime_type}}</td>
</tr> </tr>
@if (metadata()?.has_archive_version) { @if (metadata()?.has_archive_version) {
<tr> <tr class="align-middle">
<td i18n>Archive SHA256 checksum</td> <td i18n>Archive SHA256 checksum</td>
<td>{{metadata()?.archive_checksum}}</td> <td><span class="badge bg-light font-monospace text-wrap text-break text-start">{{metadata()?.archive_checksum}}</span></td>
</tr> </tr>
} }
@if (metadata()?.has_archive_version) { @if (metadata()?.has_archive_version) {
@@ -408,7 +386,7 @@
<a ngbNavLink i18n>Permissions</a> <a ngbNavLink i18n>Permissions</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
<div class="mb-3"> <div class="mb-3">
<pngx-permissions-form [users]="users()" formControlName="permissions_form"></pngx-permissions-form> <pngx-permissions-form title="" [users]="users()" formControlName="permissions_form"></pngx-permissions-form>
</div> </div>
</ng-template> </ng-template>
</li> </li>
@@ -499,6 +477,29 @@
(loadError)="onError($event)" (loadError)="onError($event)"
(afterLoadComplete)="pdfPreviewLoaded($event)"> (afterLoadComplete)="pdfPreviewLoaded($event)">
</pngx-pdf-viewer> </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> </div>
} @else { } @else {
<object [data]="previewUrl() | safeUrl" class="preview-sticky" width="100%"></object> <object [data]="previewUrl() | safeUrl" class="preview-sticky" width="100%"></object>
@@ -60,6 +60,7 @@ import { TagService } from 'src/app/services/rest/tag.service'
import { UserService } from 'src/app/services/rest/user.service' import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.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 { WebsocketStatusService } from 'src/app/services/websocket-status.service'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component' import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
@@ -137,6 +138,7 @@ describe('DocumentDetailComponent', () => {
let openDocumentsService: OpenDocumentsService let openDocumentsService: OpenDocumentsService
let modalService: NgbModal let modalService: NgbModal
let toastService: ToastService let toastService: ToastService
let trashService: TrashService
let documentListViewService: DocumentListViewService let documentListViewService: DocumentListViewService
let settingsService: SettingsService let settingsService: SettingsService
let customFieldsService: CustomFieldsService let customFieldsService: CustomFieldsService
@@ -285,6 +287,7 @@ describe('DocumentDetailComponent', () => {
documentService = TestBed.inject(DocumentService) documentService = TestBed.inject(DocumentService)
modalService = TestBed.inject(NgbModal) modalService = TestBed.inject(NgbModal)
toastService = TestBed.inject(ToastService) toastService = TestBed.inject(ToastService)
trashService = TestBed.inject(TrashService)
documentListViewService = TestBed.inject(DocumentListViewService) documentListViewService = TestBed.inject(DocumentListViewService)
settingsService = TestBed.inject(SettingsService) settingsService = TestBed.inject(SettingsService)
settingsService.currentUser.set({ id: 1 }) settingsService.currentUser.set({ id: 1 })
@@ -775,7 +778,7 @@ describe('DocumentDetailComponent', () => {
component.title.set('Foo Bar') component.title.set('Foo Bar')
const closeSpy = jest.spyOn(component, 'close') const closeSpy = jest.spyOn(component, 'close')
const patchSpy = jest.spyOn(documentService, 'patch') const patchSpy = jest.spyOn(documentService, 'patch')
const toastSpy = jest.spyOn(toastService, 'showInfo') const toastSpy = jest.spyOn(toastService, 'showSuccess')
patchSpy.mockImplementation((o) => of(doc)) patchSpy.mockImplementation((o) => of(doc))
component.save(true) component.save(true)
expect(patchSpy).toHaveBeenCalled() expect(patchSpy).toHaveBeenCalled()
@@ -790,7 +793,7 @@ describe('DocumentDetailComponent', () => {
component.title.set('Foo Bar') component.title.set('Foo Bar')
const closeSpy = jest.spyOn(component, 'close') const closeSpy = jest.spyOn(component, 'close')
const patchSpy = jest.spyOn(documentService, 'patch') const patchSpy = jest.spyOn(documentService, 'patch')
const toastSpy = jest.spyOn(toastService, 'showInfo') const toastSpy = jest.spyOn(toastService, 'showSuccess')
patchSpy.mockImplementation((o) => of(doc)) patchSpy.mockImplementation((o) => of(doc))
component.save() component.save()
expect(patchSpy).toHaveBeenCalled() expect(patchSpy).toHaveBeenCalled()
@@ -836,7 +839,7 @@ describe('DocumentDetailComponent', () => {
component.title.set('Foo Bar') component.title.set('Foo Bar')
const closeSpy = jest.spyOn(component, 'close') const closeSpy = jest.spyOn(component, 'close')
const patchSpy = jest.spyOn(documentService, 'patch') const patchSpy = jest.spyOn(documentService, 'patch')
const toastSpy = jest.spyOn(toastService, 'showInfo') const toastSpy = jest.spyOn(toastService, 'showSuccess')
patchSpy.mockImplementation(() => patchSpy.mockImplementation(() =>
throwError(() => new Error('failed to save')) throwError(() => new Error('failed to save'))
) )
@@ -957,12 +960,16 @@ describe('DocumentDetailComponent', () => {
const modalSpy = jest.spyOn(modalService, 'open') const modalSpy = jest.spyOn(modalService, 'open')
const deleteSpy = jest.spyOn(documentService, 'delete') const deleteSpy = jest.spyOn(documentService, 'delete')
deleteSpy.mockReturnValue(of(true)) deleteSpy.mockReturnValue(of(true))
const notifySpy = jest
.spyOn(trashService, 'notifyMovedToTrash')
.mockImplementation(() => {})
component.delete() component.delete()
expect(modalSpy).toHaveBeenCalled() expect(modalSpy).toHaveBeenCalled()
const modalCloseSpy = jest.spyOn(openModal, 'close') const modalCloseSpy = jest.spyOn(openModal, 'close')
openModal.componentInstance.confirmClicked.next() openModal.componentInstance.confirmClicked.next()
expect(deleteSpy).toHaveBeenCalled() expect(deleteSpy).toHaveBeenCalled()
expect(modalCloseSpy).toHaveBeenCalled() expect(modalCloseSpy).toHaveBeenCalled()
expect(notifySpy).toHaveBeenCalledWith(expect.any(String), [doc.id])
}) })
it('should allow retry delete if error', () => { it('should allow retry delete if error', () => {
@@ -95,6 +95,7 @@ import { TagService } from 'src/app/services/rest/tag.service'
import { UserService } from 'src/app/services/rest/user.service' import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.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 { WebsocketStatusService } from 'src/app/services/websocket-status.service'
import { getFilenameFromContentDisposition } from 'src/app/utils/http' import { getFilenameFromContentDisposition } from 'src/app/utils/http'
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter' import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
@@ -221,6 +222,7 @@ export class DocumentDetailComponent
private documentListViewService = inject(DocumentListViewService) private documentListViewService = inject(DocumentListViewService)
private documentTitlePipe = inject(DocumentTitlePipe) private documentTitlePipe = inject(DocumentTitlePipe)
private toastService = inject(ToastService) private toastService = inject(ToastService)
private trashService = inject(TrashService)
private settings = inject(SettingsService) private settings = inject(SettingsService)
private storagePathService = inject(StoragePathService) private storagePathService = inject(StoragePathService)
private permissionsService = inject(PermissionsService) private permissionsService = inject(PermissionsService)
@@ -1349,7 +1351,7 @@ export class DocumentDetailComponent
this.store.next(newValues) this.store.next(newValues)
this.openDocumentService.setDirty(this.document(), false) this.openDocumentService.setDirty(this.document(), false)
this.openDocumentService.save() this.openDocumentService.save()
this.toastService.showInfo( this.toastService.showSuccess(
$localize`Document "${newValues.title}" saved successfully.` $localize`Document "${newValues.title}" saved successfully.`
) )
this.networkActive.set(false) this.networkActive.set(false)
@@ -1377,7 +1379,7 @@ export class DocumentDetailComponent
if (!canEdit) { if (!canEdit) {
// document was 'given away' // document was 'given away'
this.openDocumentService.setDirty(this.document(), false) this.openDocumentService.setDirty(this.document(), false)
this.toastService.showInfo( this.toastService.showSuccess(
$localize`Document "${this.document().title}" saved successfully.` $localize`Document "${this.document().title}" saved successfully.`
) )
this.close() this.close()
@@ -1490,6 +1492,10 @@ export class DocumentDetailComponent
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({
next: () => { next: () => {
this.trashService.notifyMovedToTrash(
$localize`Moved "${this.document().title}" to the trash`,
[this.document().id]
)
modal.close() modal.close()
this.close() this.close()
}, },
@@ -33,7 +33,9 @@ describe('DocumentVersionDropdownComponent', () => {
| 'updateVersionLabel' | 'updateVersionLabel'
> >
> >
let toastService: jest.Mocked<Pick<ToastService, 'showError' | 'showInfo'>> let toastService: jest.Mocked<
Pick<ToastService, 'showError' | 'showInfo' | 'showSuccess'>
>
let finished$: Subject<{ taskId: string }> let finished$: Subject<{ taskId: string }>
let failed$: Subject<{ taskId: string; message?: string }> let failed$: Subject<{ taskId: string; message?: string }>
let modalService: jest.Mocked<Pick<NgbModal, 'open'>> let modalService: jest.Mocked<Pick<NgbModal, 'open'>>
@@ -55,6 +57,7 @@ describe('DocumentVersionDropdownComponent', () => {
toastService = { toastService = {
showError: jest.fn(), showError: jest.fn(),
showInfo: jest.fn(), showInfo: jest.fn(),
showSuccess: jest.fn(),
} }
permissionsService = { permissionsService = {
currentUserCan: jest.fn().mockReturnValue(true), currentUserCan: jest.fn().mockReturnValue(true),
@@ -416,6 +419,6 @@ describe('DocumentVersionDropdownComponent', () => {
expect(selectedEmitSpy).toHaveBeenCalledWith(2) expect(selectedEmitSpy).toHaveBeenCalledWith(2)
expect(component.newVersionLabel).toEqual('') expect(component.newVersionLabel).toEqual('')
expect(modal.close).toHaveBeenCalled() expect(modal.close).toHaveBeenCalled()
expect(toastService.showInfo).toHaveBeenCalled() expect(toastService.showSuccess).toHaveBeenCalled()
}) })
}) })
@@ -320,7 +320,7 @@ export class DocumentVersionDropdownComponent
} }
this.newVersionLabel = '' this.newVersionLabel = ''
modal.close() modal.close()
this.toastService.showInfo( this.toastService.showSuccess(
$localize`Existing document added as a version.` $localize`Existing document added as a version.`
) )
}, },
@@ -39,6 +39,7 @@ import { TagService } from 'src/app/services/rest/tag.service'
import { UserService } from 'src/app/services/rest/user.service' import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { TrashService } from 'src/app/services/trash.service'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component' 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' import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
@@ -74,6 +75,7 @@ describe('BulkEditorComponent', () => {
let documentListViewService: DocumentListViewService let documentListViewService: DocumentListViewService
let documentService: DocumentService let documentService: DocumentService
let toastService: ToastService let toastService: ToastService
let trashService: TrashService
let modalService: NgbModal let modalService: NgbModal
let tagService: TagService let tagService: TagService
let correspondentsService: CorrespondentService let correspondentsService: CorrespondentService
@@ -185,6 +187,7 @@ describe('BulkEditorComponent', () => {
documentListViewService = TestBed.inject(DocumentListViewService) documentListViewService = TestBed.inject(DocumentListViewService)
documentService = TestBed.inject(DocumentService) documentService = TestBed.inject(DocumentService)
toastService = TestBed.inject(ToastService) toastService = TestBed.inject(ToastService)
trashService = TestBed.inject(TrashService)
modalService = TestBed.inject(NgbModal) modalService = TestBed.inject(NgbModal)
tagService = TestBed.inject(TagService) tagService = TestBed.inject(TagService)
correspondentsService = TestBed.inject(CorrespondentService) correspondentsService = TestBed.inject(CorrespondentService)
@@ -1164,10 +1167,14 @@ describe('BulkEditorComponent', () => {
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/delete/` `${environment.apiBaseUrl}documents/delete/`
) )
const notifySpy = jest
.spyOn(trashService, 'notifyMovedToTrash')
.mockImplementation(() => {})
req.flush(true) req.flush(true)
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
documents: [3, 4], documents: [3, 4],
}) })
expect(notifySpy).toHaveBeenCalledWith(expect.any(String), [3, 4])
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload ) // list reload
@@ -1177,10 +1184,19 @@ describe('BulkEditorComponent', () => {
component.showConfirmationDialogs = false component.showConfirmationDialogs = false
fixture.detectChanges() fixture.detectChanges()
jest
.spyOn(documentListViewService, 'allSelected', 'get')
.mockReturnValue(true)
component.applyDelete() component.applyDelete()
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/delete/` `${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', () => { it('should not be accessible with insufficient global permissions', () => {
@@ -1416,7 +1432,7 @@ describe('BulkEditorComponent', () => {
const mergeAsVersionsSpy = jest const mergeAsVersionsSpy = jest
.spyOn(documentService, 'mergeDocumentsAsVersions') .spyOn(documentService, 'mergeDocumentsAsVersions')
.mockReturnValue(of(true)) .mockReturnValue(of(true))
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
fixture.detectChanges() fixture.detectChanges()
component.mergeSelectedAsVersions() component.mergeSelectedAsVersions()
@@ -1432,7 +1448,9 @@ describe('BulkEditorComponent', () => {
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) )
expect(documentListViewService.selected.size).toEqual(0) expect(documentListViewService.selected.size).toEqual(0)
expect(toastInfoSpy).toHaveBeenCalledWith('Documents merged as versions.') expect(toastSuccessSpy).toHaveBeenCalledWith(
'Documents merged as versions.'
)
}) })
it('should not report success when merging documents as versions fails', () => { it('should not report success when merging documents as versions fails', () => {
@@ -1458,7 +1476,7 @@ describe('BulkEditorComponent', () => {
jest jest
.spyOn(documentService, 'mergeDocumentsAsVersions') .spyOn(documentService, 'mergeDocumentsAsVersions')
.mockReturnValue(throwError(() => new Error('failed'))) .mockReturnValue(throwError(() => new Error('failed')))
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
fixture.detectChanges() fixture.detectChanges()
@@ -1467,7 +1485,7 @@ describe('BulkEditorComponent', () => {
modal.componentInstance.confirm() modal.componentInstance.confirm()
expect(toastErrorSpy).toHaveBeenCalled() expect(toastErrorSpy).toHaveBeenCalled()
expect(toastInfoSpy).not.toHaveBeenCalled() expect(toastSuccessSpy).not.toHaveBeenCalled()
}) })
it('should support bulk download with archive, originals or both and file formatting', () => { it('should support bulk download with archive, originals or both and file formatting', () => {
@@ -1846,7 +1864,7 @@ describe('BulkEditorComponent', () => {
.spyOn(documentListViewService, 'selected', 'get') .spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([3, 4])) .mockReturnValue(new Set([3, 4]))
fixture.detectChanges() fixture.detectChanges()
const toastServiceShowInfoSpy = jest.spyOn(toastService, 'showInfo') const toastServiceShowInfoSpy = jest.spyOn(toastService, 'showSuccess')
const toastServiceShowErrorSpy = jest.spyOn(toastService, 'showError') const toastServiceShowErrorSpy = jest.spyOn(toastService, 'showError')
const listReloadSpy = jest.spyOn(documentListViewService, 'reload') const listReloadSpy = jest.spyOn(documentListViewService, 'reload')
@@ -20,7 +20,15 @@ import {
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { saveAs } from 'file-saver' import { saveAs } from 'file-saver'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { first, map, Observable, Subject, switchMap, takeUntil } from 'rxjs' import {
first,
map,
Observable,
Subject,
switchMap,
takeUntil,
tap,
} from 'rxjs'
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component' import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
import { CustomField } from 'src/app/data/custom-field' import { CustomField } from 'src/app/data/custom-field'
import { MatchingModel } from 'src/app/data/matching-model' import { MatchingModel } from 'src/app/data/matching-model'
@@ -49,6 +57,7 @@ import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { TagService } from 'src/app/services/rest/tag.service' import { TagService } from 'src/app/services/rest/tag.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.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 { flattenTags } from 'src/app/utils/flatten-tags'
import { queryParamsFromFilterRules } from 'src/app/utils/query-params' 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' import { MergeAsVersionsConfirmDialogComponent } from '../../common/confirm-dialog/merge-as-versions-confirm-dialog/merge-as-versions-confirm-dialog.component'
@@ -99,6 +108,7 @@ export class BulkEditorComponent
private openDocumentService = inject(OpenDocumentsService) private openDocumentService = inject(OpenDocumentsService)
private settings = inject(SettingsService) private settings = inject(SettingsService)
private toastService = inject(ToastService) private toastService = inject(ToastService)
private trashService = inject(TrashService)
private storagePathService = inject(StoragePathService) private storagePathService = inject(StoragePathService)
private customFieldService = inject(CustomFieldsService) private customFieldService = inject(CustomFieldsService)
public readonly permissionService = inject(PermissionsService) public readonly permissionService = inject(PermissionsService)
@@ -306,7 +316,7 @@ export class BulkEditorComponent
next: () => { next: () => {
this.handleOperationSuccess(modal, options.clearSelection ?? false) this.handleOperationSuccess(modal, options.clearSelection ?? false)
if (options.successMessage) { if (options.successMessage) {
this.toastService.showInfo(options.successMessage) this.toastService.showSuccess(options.successMessage)
} }
}, },
error: (error) => this.handleOperationError(modal, error), error: (error) => this.handleOperationError(modal, error),
@@ -884,19 +894,33 @@ export class BulkEditorComponent
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => { .subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled.set(false)
this.executeDocumentAction( this.deleteSelected(modal)
modal,
this.documentService.deleteDocuments(this.getSelectionQuery())
)
}) })
} else { } else {
this.executeDocumentAction( this.deleteSelected(null)
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() { downloadSelected() {
this.awaitingDownload.set(true) this.awaitingDownload.set(true)
let downloadFileType: string = let downloadFileType: string =
@@ -1065,7 +1089,7 @@ export class BulkEditorComponent
dialog.selection = this.getSelectionQuery() dialog.selection = this.getSelectionQuery()
dialog.selectionCount = this.getSelectionSize() dialog.selectionCount = this.getSelectionSize()
dialog.succeeded.subscribe((result) => { dialog.succeeded.subscribe((result) => {
this.toastService.showInfo($localize`Custom fields updated.`) this.toastService.showSuccess($localize`Custom fields updated.`)
this.list.reload() this.list.reload()
this.list.reduceSelectionToFilter() this.list.reduceSelectionToFilter()
this.list.selected.forEach((id) => { this.list.selected.forEach((id) => {
@@ -487,7 +487,7 @@ describe('DocumentListComponent', () => {
delete modifiedView.name delete modifiedView.name
const savedViewServicePatch = jest.spyOn(savedViewService, 'patch') const savedViewServicePatch = jest.spyOn(savedViewService, 'patch')
savedViewServicePatch.mockReturnValue(of(modifiedView)) savedViewServicePatch.mockReturnValue(of(modifiedView))
const toastSpy = jest.spyOn(toastService, 'showInfo') const toastSpy = jest.spyOn(toastService, 'showSuccess')
component.saveViewConfig() component.saveViewConfig()
expect(savedViewServicePatch).toHaveBeenCalledWith(modifiedView) expect(savedViewServicePatch).toHaveBeenCalledWith(modifiedView)
@@ -592,7 +592,7 @@ describe('DocumentListComponent', () => {
let openModal: NgbModalRef let openModal: NgbModalRef
modalService.activeInstances.subscribe((modal) => (openModal = modal[0])) modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
const modalSpy = jest.spyOn(modalService, 'open') const modalSpy = jest.spyOn(modalService, 'open')
const toastSpy = jest.spyOn(toastService, 'showInfo') const toastSpy = jest.spyOn(toastService, 'showSuccess')
const savedViewServiceCreate = jest.spyOn(savedViewService, 'create') const savedViewServiceCreate = jest.spyOn(savedViewService, 'create')
jest jest
.spyOn(savedViewService, 'dashboardViews', 'get') .spyOn(savedViewService, 'dashboardViews', 'get')
@@ -418,7 +418,7 @@ export class DocumentListComponent
next: (view) => { next: (view) => {
this.activeSavedView = view this.activeSavedView = view
this.unmodifiedSavedView = view this.unmodifiedSavedView = view
this.toastService.showInfo( this.toastService.showSuccess(
$localize`View "${this.list.activeSavedViewTitle}" saved successfully.` $localize`View "${this.list.activeSavedViewTitle}" saved successfully.`
) )
this.unmodifiedFilterRules.set(this.list.filterRules) this.unmodifiedFilterRules.set(this.list.filterRules)
@@ -493,7 +493,7 @@ export class DocumentListComponent
.subscribe({ .subscribe({
next: () => { next: () => {
modal.close() modal.close()
this.toastService.showInfo( this.toastService.showSuccess(
$localize`View "${savedView.name}" created successfully.` $localize`View "${savedView.name}" created successfully.`
) )
}, },
@@ -119,7 +119,7 @@ describe('FileDropComponent', () => {
} }
component.onDrop(dragEvent as DragEvent) component.onDrop(dragEvent as DragEvent)
advanceTimers(3000) advanceTimers(3000)
expect(toastSpy).toHaveBeenCalled() expect(toastSpy).not.toHaveBeenCalled()
expect(uploadSpy).toHaveBeenCalled() expect(uploadSpy).toHaveBeenCalled()
}) })
@@ -159,7 +159,7 @@ describe('FileDropComponent', () => {
component.onDrop(dragEvent as DragEvent) component.onDrop(dragEvent as DragEvent)
await flushPromises() await flushPromises()
advanceTimers(3000) advanceTimers(3000)
expect(toastSpy).toHaveBeenCalled() expect(toastSpy).not.toHaveBeenCalled()
expect(uploadSpy).toHaveBeenCalled() expect(uploadSpy).toHaveBeenCalled()
}) })
@@ -227,7 +227,7 @@ describe('FileDropComponent', () => {
} }
component.onDrop(dragEvent as DragEvent) component.onDrop(dragEvent as DragEvent)
advanceTimers(3000) advanceTimers(3000)
expect(toastSpy).toHaveBeenCalled() expect(toastSpy).not.toHaveBeenCalled()
expect(uploadSpy).toHaveBeenCalled() expect(uploadSpy).toHaveBeenCalled()
}) })
@@ -134,8 +134,7 @@ export class FileDropComponent {
Promise.all(promises) Promise.all(promises)
.then((results) => { .then((results) => {
files.push(...[].concat(...results)) files.push(...[].concat(...results))
this.toastService.showInfo($localize`Initiating upload...`, 3000) this.uploadDocumentsService.uploadFiles(files)
files.forEach((file) => this.uploadDocumentsService.uploadFile(file))
}) })
.catch((e) => { .catch((e) => {
this.toastService.showError( this.toastService.showError(
@@ -143,8 +142,7 @@ export class FileDropComponent {
) )
}) })
} else if (files.length) { } else if (files.length) {
this.toastService.showInfo($localize`Initiating upload...`, 3000) this.uploadDocumentsService.uploadFiles(files)
files.forEach((file) => this.uploadDocumentsService.uploadFile(file))
} }
this.onDragLeave(event, true) this.onDragLeave(event, true)
@@ -107,7 +107,7 @@ describe('CustomFieldsComponent', () => {
let modal: NgbModalRef let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1])) modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const reloadSpy = jest.spyOn(component, 'reload') const reloadSpy = jest.spyOn(component, 'reload')
component.editField() component.editField()
@@ -131,7 +131,7 @@ describe('CustomFieldsComponent', () => {
let modal: NgbModalRef let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1])) modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const reloadSpy = jest.spyOn(component, 'reload') const reloadSpy = jest.spyOn(component, 'reload')
const editButton = fixture.debugElement const editButton = fixture.debugElement
@@ -82,7 +82,9 @@ export class CustomFieldsComponent
modal.componentInstance.succeeded modal.componentInstance.succeeded
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((newField) => { .subscribe((newField) => {
this.toastService.showInfo($localize`Saved field "${newField.name}".`) this.toastService.showSuccess(
$localize`Saved field "${newField.name}".`
)
this.customFieldsService.clearCache() this.customFieldsService.clearCache()
this.settingsService.initializeDisplayFields() this.settingsService.initializeDisplayFields()
this.documentService.reload() this.documentService.reload()
@@ -109,7 +111,9 @@ export class CustomFieldsComponent
this.customFieldsService.delete(field).subscribe({ this.customFieldsService.delete(field).subscribe({
next: () => { next: () => {
modal.close() modal.close()
this.toastService.showInfo($localize`Deleted field "${field.name}"`) this.toastService.showSuccess(
$localize`Deleted field "${field.name}"`
)
this.customFieldsService.clearCache() this.customFieldsService.clearCache()
this.settingsService.initializeDisplayFields() this.settingsService.initializeDisplayFields()
this.documentService.reload() this.documentService.reload()
@@ -160,7 +160,7 @@ describe('ManagementListComponent', () => {
let modal: NgbModalRef let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1])) modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const reloadSpy = jest.spyOn(component, 'reloadData') const reloadSpy = jest.spyOn(component, 'reloadData')
component.openCreateDialog() component.openCreateDialog()
@@ -183,7 +183,7 @@ describe('ManagementListComponent', () => {
let modal: NgbModalRef let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1])) modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const reloadSpy = jest.spyOn(component, 'reloadData') const reloadSpy = jest.spyOn(component, 'reloadData')
component.openEditDialog(tags[0]) component.openEditDialog(tags[0])
@@ -387,7 +387,7 @@ describe('ManagementListComponent', () => {
expect(bulkEditPermsSpy).toHaveBeenCalled() expect(bulkEditPermsSpy).toHaveBeenCalled()
expect(errorToastSpy).toHaveBeenCalled() expect(errorToastSpy).toHaveBeenCalled()
const successToastSpy = jest.spyOn(toastService, 'showInfo') const successToastSpy = jest.spyOn(toastService, 'showSuccess')
bulkEditPermsSpy.mockReturnValueOnce(of('OK')) bulkEditPermsSpy.mockReturnValueOnce(of('OK'))
modal.componentInstance.confirmClicked.emit({ modal.componentInstance.confirmClicked.emit({
permissions: {}, permissions: {},
@@ -452,7 +452,7 @@ describe('ManagementListComponent', () => {
) )
expect(errorToastSpy).toHaveBeenCalled() expect(errorToastSpy).toHaveBeenCalled()
const successToastSpy = jest.spyOn(toastService, 'showInfo') const successToastSpy = jest.spyOn(toastService, 'showSuccess')
bulkEditSpy.mockReturnValueOnce(of('OK')) bulkEditSpy.mockReturnValueOnce(of('OK'))
modal.componentInstance.confirmClicked.emit(null) modal.componentInstance.confirmClicked.emit(null)
expect(bulkEditSpy).toHaveBeenCalled() expect(bulkEditSpy).toHaveBeenCalled()
@@ -224,7 +224,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
activeModal.componentInstance.dialogMode.set(EditDialogMode.CREATE) activeModal.componentInstance.dialogMode.set(EditDialogMode.CREATE)
activeModal.componentInstance.succeeded.subscribe(() => { activeModal.componentInstance.succeeded.subscribe(() => {
this.reloadData() this.reloadData()
this.toastService.showInfo( this.toastService.showSuccess(
$localize`Successfully created ${this.typeName}.` $localize`Successfully created ${this.typeName}.`
) )
}) })
@@ -244,7 +244,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
activeModal.componentInstance.dialogMode.set(EditDialogMode.EDIT) activeModal.componentInstance.dialogMode.set(EditDialogMode.EDIT)
activeModal.componentInstance.succeeded.subscribe(() => { activeModal.componentInstance.succeeded.subscribe(() => {
this.reloadData() this.reloadData()
this.toastService.showInfo( this.toastService.showSuccess(
$localize`Successfully updated ${this.typeName} "${object.name}".` $localize`Successfully updated ${this.typeName} "${object.name}".`
) )
}) })
@@ -428,7 +428,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
.subscribe({ .subscribe({
next: () => { next: () => {
modal.close() modal.close()
this.toastService.showInfo( this.toastService.showSuccess(
$localize`Permissions updated successfully` $localize`Permissions updated successfully`
) )
this.reloadData() this.reloadData()
@@ -468,7 +468,9 @@ export abstract class ManagementListComponent<T extends MatchingModel>
.subscribe({ .subscribe({
next: () => { next: () => {
modal.close() modal.close()
this.toastService.showInfo($localize`Objects deleted successfully`) this.toastService.showSuccess(
$localize`Objects deleted successfully`
)
this.reloadData() this.reloadData()
}, },
error: (error) => { error: (error) => {
@@ -184,7 +184,7 @@ describe('MailComponent', () => {
component.editMailAccount(mailAccounts[0] as MailAccount) component.editMailAccount(mailAccounts[0] as MailAccount)
let editDialog = modal.componentInstance as MailAccountEditDialogComponent let editDialog = modal.componentInstance as MailAccountEditDialogComponent
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
editDialog.failed.emit() editDialog.failed.emit()
expect(toastErrorSpy).toHaveBeenCalled() expect(toastErrorSpy).toHaveBeenCalled()
editDialog.succeeded.emit(mailAccounts[0] as any) editDialog.succeeded.emit(mailAccounts[0] as any)
@@ -203,7 +203,7 @@ describe('MailComponent', () => {
const deleteDialog = modal.componentInstance as ConfirmDialogComponent const deleteDialog = modal.componentInstance as ConfirmDialogComponent
const deleteSpy = jest.spyOn(mailAccountService, 'delete') const deleteSpy = jest.spyOn(mailAccountService, 'delete')
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const listAllSpy = jest.spyOn(mailAccountService, 'listAll') const listAllSpy = jest.spyOn(mailAccountService, 'listAll')
deleteSpy.mockReturnValueOnce( deleteSpy.mockReturnValueOnce(
throwError(() => new Error('error deleting mail account')) throwError(() => new Error('error deleting mail account'))
@@ -242,7 +242,7 @@ describe('MailComponent', () => {
component.editMailRule(mailRules[0] as MailRule) component.editMailRule(mailRules[0] as MailRule)
const editDialog = modal.componentInstance as MailRuleEditDialogComponent const editDialog = modal.componentInstance as MailRuleEditDialogComponent
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
editDialog.failed.emit() editDialog.failed.emit()
expect(toastErrorSpy).toHaveBeenCalled() expect(toastErrorSpy).toHaveBeenCalled()
editDialog.succeeded.emit(mailRules[0] as any) editDialog.succeeded.emit(mailRules[0] as any)
@@ -272,7 +272,7 @@ describe('MailComponent', () => {
const deleteDialog = modal.componentInstance as ConfirmDialogComponent const deleteDialog = modal.componentInstance as ConfirmDialogComponent
const deleteSpy = jest.spyOn(mailRuleService, 'delete') const deleteSpy = jest.spyOn(mailRuleService, 'delete')
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const listAllSpy = jest.spyOn(mailRuleService, 'listAll') const listAllSpy = jest.spyOn(mailRuleService, 'listAll')
deleteSpy.mockReturnValueOnce( deleteSpy.mockReturnValueOnce(
throwError(() => new Error('error deleting mail rule "rule1"')) throwError(() => new Error('error deleting mail rule "rule1"'))
@@ -303,7 +303,7 @@ describe('MailComponent', () => {
let modal: NgbModalRef let modal: NgbModalRef
modalService.activeInstances.subscribe((refs) => (modal = refs[0])) modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const rulePatchSpy = jest.spyOn(mailRuleService, 'patch') const rulePatchSpy = jest.spyOn(mailRuleService, 'patch')
component.editPermissions(mailRules[0] as MailRule) component.editPermissions(mailRules[0] as MailRule)
expect(modal).not.toBeUndefined() expect(modal).not.toBeUndefined()
@@ -356,7 +356,7 @@ describe('MailComponent', () => {
By.css('input[type="checkbox"]') By.css('input[type="checkbox"]')
) )
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
// fail first // fail first
patchSpy.mockReturnValueOnce( patchSpy.mockReturnValueOnce(
throwError(() => new Error('Error getting config')) throwError(() => new Error('Error getting config'))
@@ -380,7 +380,7 @@ describe('MailComponent', () => {
jest jest
.spyOn(activatedRoute, 'queryParamMap', 'get') .spyOn(activatedRoute, 'queryParamMap', 'get')
.mockReturnValue(of(convertToParamMap(queryParams))) .mockReturnValue(of(convertToParamMap(queryParams)))
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
completeSetup() completeSetup()
expect(toastInfoSpy).toHaveBeenCalled() expect(toastInfoSpy).toHaveBeenCalled()
}) })
@@ -135,7 +135,9 @@ export class MailComponent
if (params.get('oauth_success')) { if (params.get('oauth_success')) {
const success = params.get('oauth_success') === '1' const success = params.get('oauth_success') === '1'
if (success) { if (success) {
this.toastService.showInfo($localize`OAuth2 authentication success`) this.toastService.showSuccess(
$localize`OAuth2 authentication success`
)
this.oAuthAccountId = parseInt(params.get('account_id')) this.oAuthAccountId = parseInt(params.get('account_id'))
if (this.mailAccounts().length > 0) { if (this.mailAccounts().length > 0) {
this.editMailAccount( this.editMailAccount(
@@ -169,7 +171,7 @@ export class MailComponent
modal.componentInstance.succeeded modal.componentInstance.succeeded
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((newMailAccount) => { .subscribe((newMailAccount) => {
this.toastService.showInfo( this.toastService.showSuccess(
$localize`Saved account "${newMailAccount.name}".` $localize`Saved account "${newMailAccount.name}".`
) )
this.mailAccountService.clearCache() this.mailAccountService.clearCache()
@@ -200,7 +202,7 @@ export class MailComponent
this.mailAccountService.delete(account).subscribe({ this.mailAccountService.delete(account).subscribe({
next: () => { next: () => {
modal.close() modal.close()
this.toastService.showInfo( this.toastService.showSuccess(
$localize`Deleted mail account "${account.name}"` $localize`Deleted mail account "${account.name}"`
) )
this.mailAccountService.clearCache() this.mailAccountService.clearCache()
@@ -248,7 +250,9 @@ export class MailComponent
modal.componentInstance.succeeded modal.componentInstance.succeeded
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((newMailRule) => { .subscribe((newMailRule) => {
this.toastService.showInfo($localize`Saved rule "${newMailRule.name}".`) this.toastService.showSuccess(
$localize`Saved rule "${newMailRule.name}".`
)
this.mailRuleService.clearCache() this.mailRuleService.clearCache()
this.mailRuleService this.mailRuleService
.listAll(null, null, { full_perms: true }) .listAll(null, null, { full_perms: true })
@@ -273,7 +277,7 @@ export class MailComponent
onMailRuleEnableToggled(rule: MailRule) { onMailRuleEnableToggled(rule: MailRule) {
this.mailRuleService.patch(rule).subscribe({ this.mailRuleService.patch(rule).subscribe({
next: () => { next: () => {
this.toastService.showInfo( this.toastService.showSuccess(
rule.enabled rule.enabled
? $localize`Rule "${rule.name}" enabled.` ? $localize`Rule "${rule.name}" enabled.`
: $localize`Rule "${rule.name}" disabled.` : $localize`Rule "${rule.name}" disabled.`
@@ -302,7 +306,7 @@ export class MailComponent
this.mailRuleService.delete(rule).subscribe({ this.mailRuleService.delete(rule).subscribe({
next: () => { next: () => {
modal.close() modal.close()
this.toastService.showInfo( this.toastService.showSuccess(
$localize`Deleted mail rule "${rule.name}"` $localize`Deleted mail rule "${rule.name}"`
) )
this.mailRuleService.clearCache() this.mailRuleService.clearCache()
@@ -338,7 +342,7 @@ export class MailComponent
object['set_permissions'] = permissions['set_permissions'] object['set_permissions'] = permissions['set_permissions']
service.patch(object).subscribe({ service.patch(object).subscribe({
next: () => { next: () => {
this.toastService.showInfo($localize`Permissions updated`) this.toastService.showSuccess($localize`Permissions updated`)
modal.close() modal.close()
}, },
error: (e) => { error: (e) => {
@@ -96,7 +96,7 @@ describe('ProcessedMailDialogComponent', () => {
// select a couple of mails and delete // select a couple of mails and delete
component.selectedMailIds().add(5) component.selectedMailIds().add(5)
component.selectedMailIds().add(6) component.selectedMailIds().add(6)
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
component.deleteSelected() component.deleteSelected()
const delReq = httpTestingController.expectOne( const delReq = httpTestingController.expectOne(
@@ -67,7 +67,7 @@ export class ProcessedMailDialogComponent implements OnInit {
this.processedMailService this.processedMailService
.bulk_delete(Array.from(this.selectedMailIds())) .bulk_delete(Array.from(this.selectedMailIds()))
.subscribe(() => { .subscribe(() => {
this.toastService.showInfo($localize`Processed mail(s) deleted`) this.toastService.showSuccess($localize`Processed mail(s) deleted`)
this.loadProcessedMails() this.loadProcessedMails()
}) })
} }
@@ -227,7 +227,7 @@ describe('SavedViewsComponent', () => {
}) })
it('should support delete saved view', () => { it('should support delete saved view', () => {
const toastSpy = jest.spyOn(toastService, 'showInfo') const toastSpy = jest.spyOn(toastService, 'showSuccess')
const deleteSpy = jest.spyOn(savedViewService, 'delete') const deleteSpy = jest.spyOn(savedViewService, 'delete')
deleteSpy.mockReturnValue(of(true)) deleteSpy.mockReturnValue(of(true))
component.deleteSavedView(savedViews[0] as SavedView) component.deleteSavedView(savedViews[0] as SavedView)
@@ -183,7 +183,7 @@ export class SavedViewsComponent
this.savedViews.update((savedViews) => this.savedViews.update((savedViews) =>
savedViews.filter((view) => view !== savedView) savedViews.filter((view) => view !== savedView)
) )
this.toastService.showInfo( this.toastService.showSuccess(
$localize`Saved view "${savedView.name}" deleted.` $localize`Saved view "${savedView.name}" deleted.`
) )
this.savedViewService.clearCache() this.savedViewService.clearCache()
@@ -262,7 +262,7 @@ export class SavedViewsComponent
saveOperation.subscribe({ saveOperation.subscribe({
next: () => { next: () => {
this.toastService.showInfo($localize`Views saved successfully.`) this.toastService.showSuccess($localize`Views saved successfully.`)
this.savedViewService.clearCache() this.savedViewService.clearCache()
this.reloadViews() this.reloadViews()
}, },
@@ -315,7 +315,7 @@ export class SavedViewsComponent
view['set_permissions'] = permissions.set_permissions view['set_permissions'] = permissions.set_permissions
this.savedViewService.patch(view as SavedView).subscribe({ this.savedViewService.patch(view as SavedView).subscribe({
next: () => { next: () => {
this.toastService.showInfo($localize`Permissions updated`) this.toastService.showSuccess($localize`Permissions updated`)
modal.close() modal.close()
this.reloadViews() this.reloadViews()
}, },
@@ -22,6 +22,7 @@ class MockShareLinkBundleService {
class MockToastService { class MockToastService {
showInfo = jest.fn() showInfo = jest.fn()
showSuccess = jest.fn()
showError = jest.fn() showError = jest.fn()
} }
@@ -210,7 +211,7 @@ describe('ShareLinkBundleListComponent', () => {
component.delete(sampleBundle()) component.delete(sampleBundle())
expect(service.delete).toHaveBeenCalled() expect(service.delete).toHaveBeenCalled()
expect(toastService.showInfo).toHaveBeenCalledWith( expect(toastService.showSuccess).toHaveBeenCalledWith(
expect.stringContaining('deleted.') expect.stringContaining('deleted.')
) )
expect(service.list).toHaveBeenCalledTimes(2) expect(service.list).toHaveBeenCalledTimes(2)
@@ -178,7 +178,7 @@ export class ShareLinkBundleListComponent
if (this.bundles().length === 1 && this.page() > 1) { if (this.bundles().length === 1 && this.page() > 1) {
this.page.update((page) => page - 1) this.page.update((page) => page - 1)
} }
this.toastService.showInfo($localize`Share link bundle deleted.`) this.toastService.showSuccess($localize`Share link bundle deleted.`)
this.triggerRefresh(false) this.triggerRefresh(false)
}, },
error: (e) => { error: (e) => {
@@ -15,7 +15,7 @@ describe('ShareLinkListComponent', () => {
let fixture: ComponentFixture<ShareLinkListComponent> let fixture: ComponentFixture<ShareLinkListComponent>
let service: jest.Mocked<Pick<ShareLinkService, 'list' | 'delete'>> let service: jest.Mocked<Pick<ShareLinkService, 'list' | 'delete'>>
let clipboard: Clipboard let clipboard: Clipboard
let toastService: jest.Mocked<Pick<ToastService, 'showInfo' | 'showError'>> let toastService: jest.Mocked<Pick<ToastService, 'showSuccess' | 'showError'>>
const link = { const link = {
id: 1, id: 1,
@@ -33,7 +33,7 @@ describe('ShareLinkListComponent', () => {
delete: jest.fn().mockReturnValue(of(true)), delete: jest.fn().mockReturnValue(of(true)),
} }
toastService = { toastService = {
showInfo: jest.fn(), showSuccess: jest.fn(),
showError: jest.fn(), showError: jest.fn(),
} }
@@ -130,7 +130,7 @@ describe('ShareLinkListComponent', () => {
expect(component.copiedID()).toBe(link.id) expect(component.copiedID()).toBe(link.id)
expect(fixture.nativeElement.querySelector('.badge.show')).not.toBeNull() expect(fixture.nativeElement.querySelector('.badge.show')).not.toBeNull()
expect(toastService.showInfo).not.toHaveBeenCalled() expect(toastService.showSuccess).not.toHaveBeenCalled()
jest.advanceTimersByTime(3000) jest.advanceTimersByTime(3000)
expect(component.copiedID()).toBeNull() expect(component.copiedID()).toBeNull()
@@ -142,7 +142,7 @@ describe('ShareLinkListComponent', () => {
expect(service.delete).toHaveBeenCalledWith(link) expect(service.delete).toHaveBeenCalledWith(link)
expect(service.list).toHaveBeenCalledTimes(2) expect(service.list).toHaveBeenCalledTimes(2)
expect(toastService.showInfo).toHaveBeenCalled() expect(toastService.showSuccess).toHaveBeenCalled()
}) })
it('shows an error when loading fails', () => { it('shows an error when loading fails', () => {
@@ -151,7 +151,7 @@ export class ShareLinkListComponent
if (this.links().length === 1 && this.page() > 1) { if (this.links().length === 1 && this.page() > 1) {
this.page.update((page) => page - 1) this.page.update((page) => page - 1)
} }
this.toastService.showInfo($localize`Share link deleted.`) this.toastService.showSuccess($localize`Share link deleted.`)
this.reload() this.reload()
}, },
error: (error) => { error: (error) => {
@@ -128,7 +128,7 @@ describe('WorkflowsComponent', () => {
let modal: NgbModalRef let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1])) modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const reloadSpy = jest.spyOn(component, 'reload') const reloadSpy = jest.spyOn(component, 'reload')
const createButton = fixture.debugElement.queryAll(By.css('button'))[1] const createButton = fixture.debugElement.queryAll(By.css('button'))[1]
@@ -152,7 +152,7 @@ describe('WorkflowsComponent', () => {
let modal: NgbModalRef let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1])) modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const reloadSpy = jest.spyOn(component, 'reload') const reloadSpy = jest.spyOn(component, 'reload')
const editButton = fixture.debugElement.queryAll(By.css('button'))[2] const editButton = fixture.debugElement.queryAll(By.css('button'))[2]
@@ -268,7 +268,7 @@ describe('WorkflowsComponent', () => {
By.css('input[type="checkbox"]') By.css('input[type="checkbox"]')
) )
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
// fail first // fail first
patchSpy.mockReturnValueOnce( patchSpy.mockReturnValueOnce(
throwError(() => new Error('Error getting config')) throwError(() => new Error('Error getting config'))
@@ -86,7 +86,7 @@ export class WorkflowsComponent
modal.componentInstance.succeeded modal.componentInstance.succeeded
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((newWorkflow) => { .subscribe((newWorkflow) => {
this.toastService.showInfo( this.toastService.showSuccess(
$localize`Saved workflow "${newWorkflow.name}".` $localize`Saved workflow "${newWorkflow.name}".`
) )
this.workflowService.clearCache() this.workflowService.clearCache()
@@ -138,7 +138,7 @@ export class WorkflowsComponent
this.workflowService.delete(workflow).subscribe({ this.workflowService.delete(workflow).subscribe({
next: () => { next: () => {
modal.close() modal.close()
this.toastService.showInfo( this.toastService.showSuccess(
$localize`Deleted workflow "${workflow.name}".` $localize`Deleted workflow "${workflow.name}".`
) )
this.workflowService.clearCache() this.workflowService.clearCache()
@@ -157,7 +157,7 @@ export class WorkflowsComponent
toggleWorkflowEnabled(workflow: Workflow) { toggleWorkflowEnabled(workflow: Workflow) {
this.workflowService.patch(workflow).subscribe({ this.workflowService.patch(workflow).subscribe({
next: () => { next: () => {
this.toastService.showInfo( this.toastService.showSuccess(
workflow.enabled workflow.enabled
? $localize`Enabled workflow "${workflow.name}"` ? $localize`Enabled workflow "${workflow.name}"`
: $localize`Disabled workflow "${workflow.name}"` : $localize`Disabled workflow "${workflow.name}"`
-14
View File
@@ -62,14 +62,10 @@ export const SETTINGS_KEYS = {
'general-settings:document-details:pdf-viewer-zoom-setting', 'general-settings:document-details:pdf-viewer-zoom-setting',
DATE_LOCALE: 'general-settings:date-display:date-locale', DATE_LOCALE: 'general-settings:date-display:date-locale',
DATE_FORMAT: 'general-settings:date-display:date-format', DATE_FORMAT: 'general-settings:date-display:date-format',
NOTIFICATIONS_CONSUMER_NEW_DOCUMENT:
'general-settings:notifications:consumer-new-documents',
NOTIFICATIONS_CONSUMER_SUCCESS: NOTIFICATIONS_CONSUMER_SUCCESS:
'general-settings:notifications:consumer-success', 'general-settings:notifications:consumer-success',
NOTIFICATIONS_CONSUMER_FAILED: NOTIFICATIONS_CONSUMER_FAILED:
'general-settings: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', NOTES_ENABLED: 'general-settings:notes-enabled',
AUDITLOG_ENABLED: 'general-settings:auditlog-enabled', AUDITLOG_ENABLED: 'general-settings:auditlog-enabled',
SLIM_SIDEBAR: 'general-settings:slim-sidebar', SLIM_SIDEBAR: 'general-settings:slim-sidebar',
@@ -197,11 +193,6 @@ export const SETTINGS: UiSetting[] = [
type: 'string', type: 'string',
default: 'mediumDate', default: 'mediumDate',
}, },
{
key: SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT,
type: 'boolean',
default: true,
},
{ {
key: SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS, key: SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS,
type: 'boolean', type: 'boolean',
@@ -212,11 +203,6 @@ export const SETTINGS: UiSetting[] = [
type: 'boolean', type: 'boolean',
default: true, default: true,
}, },
{
key: SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD,
type: 'boolean',
default: true,
},
{ {
key: SETTINGS_KEYS.NOTES_ENABLED, key: SETTINGS_KEYS.NOTES_ENABLED,
type: 'boolean', type: 'boolean',
@@ -0,0 +1,90 @@
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('&lt;script&gt;')
})
it('should not render images', () => {
const html = pipe.transform(
'![secret](https://otherdomain.example/x.png?d=1)'
)
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=/)
})
})
+52
View File
@@ -0,0 +1,52 @@
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.getLanguage()).toEqual('')
expect(settingsService.get(SETTINGS_KEYS.DARK_MODE_ENABLED)).toBeFalsy() expect(settingsService.get(SETTINGS_KEYS.DARK_MODE_ENABLED)).toBeFalsy()
expect( expect(
settingsService.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT) settingsService.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS)
).toBeTruthy() ).toBeTruthy()
expect(settingsService.get(SETTINGS_KEYS.DOCUMENT_LIST_SIZE)).toEqual(50) expect(settingsService.get(SETTINGS_KEYS.DOCUMENT_LIST_SIZE)).toEqual(50)
expect(settingsService.get(SETTINGS_KEYS.THEME_COLOR)).toEqual('#9fbf2f') expect(settingsService.get(SETTINGS_KEYS.THEME_COLOR)).toEqual('#9fbf2f')
@@ -198,17 +198,14 @@ describe('SettingsService', () => {
settingsService.setLanguage('de-de') settingsService.setLanguage('de-de')
settingsService.set(SETTINGS_KEYS.DARK_MODE_ENABLED, true) settingsService.set(SETTINGS_KEYS.DARK_MODE_ENABLED, true)
settingsService.set( settingsService.set(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS, false)
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT,
false
)
settingsService.set(SETTINGS_KEYS.DOCUMENT_LIST_SIZE, 25) settingsService.set(SETTINGS_KEYS.DOCUMENT_LIST_SIZE, 25)
settingsService.set(SETTINGS_KEYS.THEME_COLOR, '#000000') settingsService.set(SETTINGS_KEYS.THEME_COLOR, '#000000')
expect(settingsService.getLanguage()).toEqual('de-de') expect(settingsService.getLanguage()).toEqual('de-de')
expect(settingsService.get(SETTINGS_KEYS.DARK_MODE_ENABLED)).toBeTruthy() expect(settingsService.get(SETTINGS_KEYS.DARK_MODE_ENABLED)).toBeTruthy()
expect( expect(
settingsService.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT) settingsService.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS)
).toBeFalsy() ).toBeFalsy()
expect(settingsService.get(SETTINGS_KEYS.DOCUMENT_LIST_SIZE)).toEqual(25) expect(settingsService.get(SETTINGS_KEYS.DOCUMENT_LIST_SIZE)).toEqual(25)
expect(settingsService.get(SETTINGS_KEYS.THEME_COLOR)).toEqual('#000000') expect(settingsService.get(SETTINGS_KEYS.THEME_COLOR)).toEqual('#000000')
+5 -4
View File
@@ -679,7 +679,7 @@ export class SettingsService {
.subscribe({ .subscribe({
next: () => { next: () => {
this.updateAppearanceSettings() this.updateAppearanceSettings()
this.toastService.showInfo(successMessage) this.toastService.showSuccess(successMessage)
}, },
error: (e) => { error: (e) => {
this.toastService.showError(errorMessage) this.toastService.showError(errorMessage)
@@ -741,9 +741,10 @@ export class SettingsService {
this.storeSettings() this.storeSettings()
.pipe(first()) .pipe(first())
.subscribe(() => { .subscribe(() => {
this.toastService.showInfo( this.toastService.show({
$localize`You can restart the tour from the settings page.` content: $localize`You can restart the tour from the settings page.`,
) delay: 5000,
})
}) })
} }
} }
+90 -1
View File
@@ -1,5 +1,5 @@
import { TestBed } from '@angular/core/testing' import { TestBed } from '@angular/core/testing'
import { ToastService } from './toast.service' import { Toast, ToastLevel, ToastService } from './toast.service'
describe('ToastService', () => { describe('ToastService', () => {
let toastService: ToastService let toastService: ToastService
@@ -68,6 +68,53 @@ describe('ToastService', () => {
}) })
}) })
it('keeps showSuccess toasts out of the toast list', () => {
let toasts: Toast[] = []
const popups: Toast[] = []
toastService.getToasts().subscribe((t) => (toasts = [...t]))
toastService.showToast.subscribe((t) => popups.push(t))
toastService.showSuccess('Saved')
expect(popups.map((t) => t.content)).toEqual(['Saved'])
expect(toasts).toHaveLength(0)
})
it('keeps showInfo toasts out of the toast list', () => {
let toasts: Toast[] = []
const popups: Toast[] = []
toastService.getToasts().subscribe((t) => (toasts = [...t]))
toastService.showToast.subscribe((t) => popups.push(t))
toastService.showInfo('Info toast')
toastService.showError('Error toast')
expect(popups.map((t) => t.content)).toEqual(['Info toast', 'Error toast'])
expect(toasts.map((t) => t.content)).toEqual(['Error toast'])
})
it('adds toasts to the list unless persistent is false', () => {
let toasts: Toast[] = []
toastService.getToasts().subscribe((t) => (toasts = [...t]))
toastService.show({ content: 'kept', delay: 5000 })
toastService.show({ content: 'kept too', delay: 5000, persistent: true })
toastService.show({ content: 'popup only', delay: 5000, persistent: false })
expect(toasts.map((t) => t.content)).toEqual(['kept too', 'kept'])
})
it('still pops up non-persistent toasts while popups are suppressed', () => {
const popups: Toast[] = []
toastService.suppressPopupToasts = true
toastService.showToast.subscribe((t) => popups.push(t))
toastService.show({ content: 'kept', delay: 5000 })
toastService.showInfo('popup only')
expect(popups.map((t) => t?.content)).toEqual(['popup only'])
})
it('removes toast on close', () => { it('removes toast on close', () => {
const toast = { const toast = {
title: 'Title', title: 'Title',
@@ -106,4 +153,46 @@ describe('ToastService', () => {
toastService.suppressPopupToasts = true toastService.suppressPopupToasts = true
toastService.showInfo('Info toast') 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)
})
}) })
+52 -7
View File
@@ -2,6 +2,12 @@ import { Injectable } from '@angular/core'
import { Subject } from 'rxjs' import { Subject } from 'rxjs'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
export enum ToastLevel {
Info = 'info',
Success = 'success',
Error = 'error',
}
export interface Toast { export interface Toast {
id?: string id?: string
@@ -9,15 +15,32 @@ export interface Toast {
delay: number delay: number
delayRemaining?: number
action?: any action?: any
actionName?: string actionName?: string
classname?: string /** Defaults to ToastLevel.Info */
level?: ToastLevel
error?: any 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({ @Injectable({
@@ -42,29 +65,51 @@ export class ToastService {
if (!toast.id) { if (!toast.id) {
toast.id = uuidv4() toast.id = uuidv4()
} }
toast.timestamp ??= new Date()
if (typeof toast.error === 'string') { if (typeof toast.error === 'string') {
try { try {
toast.error = JSON.parse(toast.error) toast.error = JSON.parse(toast.error)
} catch (e) {} } 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.toasts.unshift(toast)
if (!this._suppressPopupToasts) { this.toastsSubject.next(this.toasts)
}
// Popups are suppressed while the dropdown is open
if (!toast.silent && (!this._suppressPopupToasts || !persistent)) {
this.showToast.next(toast) this.showToast.next(toast)
} }
this.toastsSubject.next(this.toasts)
} }
showError(content: string, error: any = null, delay: number = 10000) { showError(content: string, error: any = null, delay: number = 10000) {
this.show({ this.show({
content: content, content: content,
delay: delay, delay: delay,
classname: 'error', level: ToastLevel.Error,
error, error,
}) })
} }
showInfo(content: string, delay: number = 5000) { showInfo(content: string, delay: number = 5000) {
this.show({ content: content, delay: delay }) this.show({ content: content, delay: delay, persistent: false })
}
/** For confirming that something the user did has completed */
showSuccess(content: string, delay: number = 5000) {
this.show({
content: content,
delay: delay,
level: ToastLevel.Success,
persistent: false,
})
}
markAllRead() {
this.toasts.forEach((toast) => (toast.read = true))
this.toastsSubject.next(this.toasts)
} }
closeToast(toast: Toast) { closeToast(toast: Toast) {
@@ -5,11 +5,15 @@ import {
HttpTestingController, HttpTestingController,
} from '@angular/common/http/testing' } from '@angular/common/http/testing'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
import { DocumentListViewService } from './document-list-view.service'
import { Toast, ToastService } from './toast.service'
import { TrashService } from './trash.service' import { TrashService } from './trash.service'
describe('TrashService', () => { describe('TrashService', () => {
let service: TrashService let service: TrashService
let httpTestingController: HttpTestingController let httpTestingController: HttpTestingController
let toastService: ToastService
let documentListViewService: DocumentListViewService
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
@@ -17,6 +21,8 @@ describe('TrashService', () => {
}) })
service = TestBed.inject(TrashService) service = TestBed.inject(TrashService)
httpTestingController = TestBed.inject(HttpTestingController) httpTestingController = TestBed.inject(HttpTestingController)
toastService = TestBed.inject(ToastService)
documentListViewService = TestBed.inject(DocumentListViewService)
}) })
it('should call correct endpoint for getTrash', () => { it('should call correct endpoint for getTrash', () => {
@@ -56,4 +62,47 @@ describe('TrashService', () => {
documents: [1, 2, 3], 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()
})
}) })
+34
View File
@@ -4,12 +4,16 @@ import { Observable } from 'rxjs'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
import { Document } from '../data/document' import { Document } from '../data/document'
import { Results } from '../data/results' import { Results } from '../data/results'
import { DocumentListViewService } from './document-list-view.service'
import { ToastLevel, ToastService } from './toast.service'
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class TrashService { export class TrashService {
private http = inject(HttpClient) private http = inject(HttpClient)
private toastService = inject(ToastService)
private documentListViewService = inject(DocumentListViewService)
public getTrash(page: number = 1): Observable<Results<Document>> { public getTrash(page: number = 1): Observable<Results<Document>> {
const httpParams = new HttpParams().set('page', page.toString()) const httpParams = new HttpParams().set('page', page.toString())
@@ -34,4 +38,34 @@ export class TrashService {
documents, 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,6 +54,31 @@ describe('UploadDocumentsService', () => {
req[0].flush('123-456') 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', () => { it('updates progress during upload and failure', () => {
const file = new File( const file = new File(
[new Blob(['testing'], { type: 'application/pdf' })], [new Blob(['testing'], { type: 'application/pdf' })],
@@ -1,6 +1,6 @@
import { HttpEventType } from '@angular/common/http' import { HttpEventType } from '@angular/common/http'
import { Injectable, inject } from '@angular/core' import { Injectable, inject } from '@angular/core'
import { Subscription } from 'rxjs' import { Subject, Subscription } from 'rxjs'
import { DocumentService } from './rest/document.service' import { DocumentService } from './rest/document.service'
import { import {
FileStatusPhase, FileStatusPhase,
@@ -16,6 +16,21 @@ export class UploadDocumentsService {
private uploadSubscriptions: Array<Subscription> = [] 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) { public uploadFile(file: File) {
let formData = new FormData() let formData = new FormData()
formData.append('document', file, file.name) formData.append('document', file, file.name)
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff. Show more