Compare commits

...
Author SHA1 Message Date
stumpylog f05a0f377e Fix: use a fractional thumbnail DPI so oversized pages render a bounded raster 2026-10-19 19:39:55 -07:00
stumpylogandClaude Sonnet 5.5 8910937682 Chore: Supersample the PDF thumbnail render 2x
Render the PDF page at twice the target size and downsample, with edge case
handling for small and odd sized pages, simplified helpers and tests, and
trimmed comments.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 19:39:55 -07:00
stumpylogandClaude Sonnet 5.5 553f64fce3 Chore: Generate PDF thumbnails with pdftoppm and Pillow instead of ImageMagick
Replace the ImageMagick convert based PDF thumbnail path with pdftoppm and
Pillow. Keeps thumbnails at 500px wide for small and odd sized pages, covers
the qpdf repair and double failure paths, removes the unused run_convert
helper, hardens thumbnail encoding, deprecates PAPERLESS_CONVERT_MEMORY_LIMIT
and PAPERLESS_CONVERT_TMPDIR, and corrects the related docs and CI.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 19:39:55 -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
113 changed files with 3842 additions and 1515 deletions

No files matched your search

+1 -1
View File
@@ -111,7 +111,7 @@ jobs:
timeout-minutes: 12
uses: $/.github/actions/apt-install
with:
packages: unpaper tesseract-ocr imagemagick ghostscript poppler-utils
packages: unpaper tesseract-ocr imagemagick ghostscript poppler-utils qpdf
- name: Configure ImageMagick
run: |
sudo cp docker/rootfs/etc/ImageMagick-6/paperless-policy.xml /etc/ImageMagick-7/policy.xml
+33
View File
@@ -1,5 +1,38 @@
# 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
### Bug Fixes
+6 -18
View File
@@ -1315,29 +1315,17 @@ valid crontab(5) expression describing when to run.
#### [`PAPERLESS_CONVERT_MEMORY_LIMIT=<num>`](#PAPERLESS_CONVERT_MEMORY_LIMIT) {#PAPERLESS_CONVERT_MEMORY_LIMIT}
: On smaller systems, or even in the case of Very Large Documents, the
consumer may explode, complaining about how it's "unable to extend
pixel cache". In such cases, try setting this to a reasonably low
value, like 32. The default is to use whatever is necessary to do
everything without writing to disk, and units are in megabytes.
!!! warning
For more information on how to use this value, you should search the
web for "MAGICK_MEMORY_LIMIT".
Defaults to 0, which disables the limit.
Deprecated and has no effect, since PDF thumbnails no longer use
ImageMagick. It will be removed in a future release.
#### [`PAPERLESS_CONVERT_TMPDIR=<path>`](#PAPERLESS_CONVERT_TMPDIR) {#PAPERLESS_CONVERT_TMPDIR}
: Similar to the memory limit, if you've got a small system and your
OS mounts /tmp as tmpfs, you should set this to a path that's on a
physical disk, like /home/your_user/tmp or something. ImageMagick
will use this as scratch space when crunching through very large
documents.
!!! warning
For more information on how to use this value, you should search the
web for "MAGICK_TMPDIR".
Default is none, which disables the temporary directory.
Deprecated and has no effect, since PDF thumbnails no longer use
ImageMagick. It will be removed in a future release.
#### [`PAPERLESS_APPS=<string>`](#PAPERLESS_APPS) {#PAPERLESS_APPS}
+4 -7
View File
@@ -177,12 +177,12 @@ to a positive number to enable polling and disable native filesystem notificatio
- `pkg-config` for mysqlclient (python dependency)
- `fonts-liberation` for generating thumbnails for plain text
files
- `imagemagick` >= 6 for PDF conversion
- `imagemagick` >= 6 for image alpha handling
- `gnupg` for decrypting GPG-encrypted email
- `libpq-dev` for PostgreSQL
- `libmagic-dev` for mime type detection
- `mariadb-client` for MariaDB compile time
- `poppler-utils` for barcode detection
- `poppler-utils` for thumbnail generation and barcode detection
Use this list for your preferred package management:
@@ -416,11 +416,8 @@ to a positive number to enable polling and disable native filesystem notificatio
You may need to change the path in the files. Example:
`ExecStart=/opt/paperless/.local/bin/celery --app paperless worker --loglevel INFO`
12. Configure ImageMagick to allow processing of PDF documents and disable
formats that Paperless-ngx does not use. Most distributions disable PDF
processing by default, since PDF documents can contain malware. If you
don't enable it, Paperless-ngx will fall back to Ghostscript for certain
steps such as thumbnail generation.
12. Harden ImageMagick by disabling formats that Paperless-ngx does not use.
PDF processing is not needed and should stay disabled.
Configure the active ImageMagick policy file (commonly
`/etc/ImageMagick-6/policy.xml` or `/etc/ImageMagick-7/policy.xml`) and
-2
View File
@@ -50,8 +50,6 @@ PAPERLESS_SECRET_KEY=change-me
#PAPERLESS_OCR_ROTATE_PAGES=true
#PAPERLESS_OCR_ROTATE_PAGES_THRESHOLD=12.0
#PAPERLESS_OCR_USER_ARGS={}
#PAPERLESS_CONVERT_MEMORY_LIMIT=0
#PAPERLESS_CONVERT_TMPDIR=/var/tmp/paperless
# Software tweaks
+1 -1
View File
@@ -28,7 +28,7 @@ module.exports = {
'abstract-paperless-service',
],
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: {
...esmPreset.moduleNameMapper,
+672 -560
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -30,6 +30,7 @@
"bootstrap": "^5.3.8",
"file-saver": "^2.0.5",
"lodash-es": "^4.18.1",
"marked": "~18.0.14",
"mime-names": "^1.0.0",
"ngx-bootstrap-icons": "^1.9.3",
"ngx-color": "^10.1.0",
+10
View File
@@ -53,6 +53,9 @@ importers:
lodash-es:
specifier: ^4.18.1
version: 4.18.1
marked:
specifier: ~18.0.14
version: 18.0.14
mime-names:
specifier: ^1.0.0
version: 1.0.0
@@ -3226,6 +3229,11 @@ packages:
make-error@1.3.6:
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
marked@18.0.14:
resolution: {integrity: sha512-mBHK6FBHuBAlhgRe88w9F0O1AbwwXJUcQibUbC/QcdTbVGAD7aWza+xt3N6oT/jCZx3/OMeS+8rnuiHZcQ9s7A==}
engines: {node: '>= 20'}
hasBin: true
material-colors@1.2.6:
resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==}
@@ -7409,6 +7417,8 @@ snapshots:
make-error@1.3.6: {}
marked@18.0.14: {}
material-colors@1.2.6: {}
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 { PermissionsService } from './services/permissions.service'
import { SettingsService } from './services/settings.service'
import { TasksService } from './services/tasks.service'
import { Toast, ToastService } from './services/toast.service'
import {
FileStatus,
@@ -34,6 +35,7 @@ describe('AppComponent', () => {
let toastService: ToastService
let router: Router
let settingsService: SettingsService
let tasksService: TasksService
let hotKeyService: HotKeyService
beforeEach(async () => {
@@ -60,6 +62,7 @@ describe('AppComponent', () => {
websocketStatusService = TestBed.inject(WebsocketStatusService)
permissionsService = TestBed.inject(PermissionsService)
settingsService = TestBed.inject(SettingsService)
tasksService = TestBed.inject(TasksService)
toastService = TestBed.inject(ToastService)
router = TestBed.inject(Router)
hotKeyService = TestBed.inject(HotKeyService)
@@ -117,30 +120,33 @@ describe('AppComponent', () => {
expect(toast.action).toBeUndefined()
})
it('should display toast on document added', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
it('should only reload tasks when a document is detected', () => {
const toastSpy = jest.spyOn(toastService, 'show')
const reloadSpy = jest.spyOn(tasksService, 'reload')
const fileStatusSubject = new Subject<FileStatus>()
jest
.spyOn(websocketStatusService, 'onDocumentDetected')
.mockReturnValue(fileStatusSubject)
component.ngOnInit()
fileStatusSubject.next(new FileStatus())
expect(toastSpy).toHaveBeenCalled()
expect(reloadSpy).toHaveBeenCalled()
expect(toastSpy).not.toHaveBeenCalled()
})
it('should suppress dashboard notifications if set', () => {
it('should add consumed documents to the bell without a popup', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest.spyOn(settingsService, 'get').mockReturnValue(true)
jest.spyOn(router, 'url', 'get').mockReturnValue('/dashboard')
const toastSpy = jest.spyOn(toastService, 'show')
const popupSpy = jest.fn()
toastService.showToast.subscribe(popupSpy)
let toasts: Toast[] = []
toastService.getToasts().subscribe((t) => (toasts = [...t]))
const fileStatusSubject = new Subject<FileStatus>()
jest
.spyOn(websocketStatusService, 'onDocumentDetected')
.spyOn(websocketStatusService, 'onDocumentConsumptionFinished')
.mockReturnValue(fileStatusSubject)
component.ngOnInit()
fileStatusSubject.next(new FileStatus())
expect(toastSpy).not.toHaveBeenCalled()
expect(toasts).toHaveLength(1)
expect(popupSpy).not.toHaveBeenCalled()
})
it('should display toast on document failed', () => {
+19 -50
View File
@@ -14,7 +14,7 @@ import {
} from './services/permissions.service'
import { SettingsService } from './services/settings.service'
import { TasksService } from './services/tasks.service'
import { ToastService } from './services/toast.service'
import { ToastLevel, ToastService } from './services/toast.service'
import { WebsocketStatusService } from './services/websocket-status.service'
@Component({
@@ -61,18 +61,6 @@ export class AppComponent implements OnInit, OnDestroy {
}
}
private showNotification(key) {
if (
this.router.url == '/dashboard' &&
this.settings.get(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD
)
) {
return false
}
return this.settings.get(key)
}
ngOnInit(): void {
this.websocketStatusService.connect()
@@ -80,29 +68,22 @@ export class AppComponent implements OnInit, OnDestroy {
.onDocumentConsumptionFinished()
.subscribe((status) => {
this.tasksService.reload()
if (
this.showNotification(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS)
) {
if (
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.Document
)
) {
this.toastService.show({
content: $localize`Document ${status.filename} was added to Paperless-ngx.`,
delay: 10000,
actionName: $localize`Open document`,
action: () => {
this.router.navigate(['documents', status.documentId])
},
})
} else {
this.toastService.show({
content: $localize`Document ${status.filename} was added to Paperless-ngx.`,
delay: 10000,
})
}
if (this.settings.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS)) {
const canView = this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.Document
)
// the upload status tray already shows this, so only keep a record
this.toastService.show({
content: $localize`Document ${status.filename} was added to Paperless-ngx.`,
delay: 10000,
level: ToastLevel.Success,
silent: true,
actionName: canView ? $localize`Open document` : undefined,
action: canView
? () => this.router.navigate(['documents', status.documentId])
: undefined,
})
}
})
@@ -110,9 +91,7 @@ export class AppComponent implements OnInit, OnDestroy {
.onDocumentConsumptionFailed()
.subscribe((status) => {
this.tasksService.reload()
if (
this.showNotification(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED)
) {
if (this.settings.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED)) {
this.toastService.showError(
$localize`Could not add ${status.filename}\: ${status.message}`
)
@@ -121,18 +100,8 @@ export class AppComponent implements OnInit, OnDestroy {
this.newDocumentSubscription = this.websocketStatusService
.onDocumentDetected()
.subscribe((status) => {
.subscribe(() => {
this.tasksService.reload()
if (
this.showNotification(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT
)
) {
this.toastService.show({
content: $localize`Document ${status.filename} is being processed by Paperless-ngx.`,
delay: 5000,
})
}
})
this.hotKeyService
@@ -190,7 +190,7 @@ export class ConfigComponent
this.initialize(config)
this.store.next(config)
this.settingsService.initializeSettings().subscribe()
this.toastService.showInfo($localize`Configuration updated`)
this.toastService.showSuccess($localize`Configuration updated`)
},
error: (e) => {
this.loading.set(false)
@@ -217,7 +217,7 @@ export class ConfigComponent
this.initialize(config)
this.store.next(config)
this.settingsService.initializeSettings().subscribe()
this.toastService.showInfo($localize`File successfully updated`)
this.toastService.showSuccess($localize`File successfully updated`)
},
error: (e) => {
this.loading.set(false)
@@ -425,10 +425,8 @@
<div class="row">
<div class="col">
<pngx-input-check i18n-title title="Show notifications when new documents are detected" formControlName="notificationsConsumerNewDocument"></pngx-input-check>
<pngx-input-check i18n-title title="Show notifications when document processing completes successfully" formControlName="notificationsConsumerSuccess"></pngx-input-check>
<pngx-input-check i18n-title title="Add notifications when document processing completes successfully" formControlName="notificationsConsumerSuccess" i18n-hint hint="These are added to the notifications menu without a popup, processing progress is shown at the bottom of the screen."></pngx-input-check>
<pngx-input-check i18n-title title="Show notifications when document processing fails" formControlName="notificationsConsumerFailed"></pngx-input-check>
<pngx-input-check i18n-title title="Suppress notifications on dashboard" formControlName="notificationsConsumerSuppressOnDashboard" i18n-hint hint="This will suppress all messages about document processing status on the dashboard."></pngx-input-check>
</div>
</div>
@@ -307,7 +307,7 @@ describe('SettingsComponent', () => {
expect(toastErrorSpy).toHaveBeenCalled()
expect(storeSpy).toHaveBeenCalled()
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
expect(setSpy).toHaveBeenCalledTimes(35)
expect(setSpy).toHaveBeenCalledTimes(33)
expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
HideableSidebarItemID.Workflows,
])
@@ -63,7 +63,7 @@ import {
SettingsService,
} from 'src/app/services/settings.service'
import { SystemStatusService } from 'src/app/services/system-status.service'
import { Toast, ToastService } from 'src/app/services/toast.service'
import { Toast, ToastLevel, ToastService } from 'src/app/services/toast.service'
import { locationReload } from 'src/app/utils/navigation'
import { CheckComponent } from '../../common/input/check/check.component'
import { ColorComponent } from '../../common/input/color/color.component'
@@ -190,10 +190,8 @@ export class SettingsComponent
searchDbOnly: new FormControl(null),
searchLink: new FormControl(null),
notificationsConsumerNewDocument: new FormControl(null),
notificationsConsumerSuccess: new FormControl(null),
notificationsConsumerFailed: new FormControl(null),
notificationsConsumerSuppressOnDashboard: new FormControl(null),
savedViewsWarnOnUnsavedChange: new FormControl(null),
sidebarViewsShowCount: new FormControl(null),
@@ -371,18 +369,12 @@ export class SettingsComponent
updateCheckingEnabled: this.settings.get(
SETTINGS_KEYS.UPDATE_CHECKING_ENABLED
),
notificationsConsumerNewDocument: this.settings.get(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT
),
notificationsConsumerSuccess: this.settings.get(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS
),
notificationsConsumerFailed: this.settings.get(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED
),
notificationsConsumerSuppressOnDashboard: this.settings.get(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD
),
savedViewsWarnOnUnsavedChange: this.settings.get(
SETTINGS_KEYS.SAVED_VIEWS_WARN_ON_UNSAVED_CHANGE
),
@@ -571,10 +563,6 @@ export class SettingsComponent
SETTINGS_KEYS.DATE_FORMAT,
this.settingsForm.value.dateFormat
)
this.settings.set(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT,
this.settingsForm.value.notificationsConsumerNewDocument
)
this.settings.set(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS,
this.settingsForm.value.notificationsConsumerSuccess
@@ -583,10 +571,6 @@ export class SettingsComponent
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED,
this.settingsForm.value.notificationsConsumerFailed
)
this.settings.set(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD,
this.settingsForm.value.notificationsConsumerSuppressOnDashboard
)
this.settings.set(
SETTINGS_KEYS.NOTES_ENABLED,
this.settingsForm.value.notesEnabled
@@ -664,8 +648,11 @@ export class SettingsComponent
let savedToast: Toast = {
content: $localize`Settings were saved successfully.`,
delay: 5000,
level: ToastLevel.Success,
persistent: false,
}
if (reloadRequired) {
savedToast.persistent = true
savedToast.content = $localize`Settings were saved successfully. Reload is required to apply some changes.`
savedToast.actionName = $localize`Reload now`
savedToast.action = () => {
@@ -99,11 +99,16 @@ describe('TrashComponent', () => {
modal.componentInstance.confirmClicked.next()
expect(toastErrorSpy).toHaveBeenCalled()
let toasts
toastService.getToasts().subscribe((allToasts) => {
toasts = [...allToasts]
})
trashSpy.mockReturnValue(of('OK'))
component.delete(documentsInTrash[0])
expect(modal).toBeDefined()
modal.componentInstance.confirmClicked.next()
expect(trashSpy).toHaveBeenCalled()
expect(toasts[0].content).toContain('deleted')
})
it('should support empty trash, show error if needed', () => {
@@ -11,7 +11,7 @@ import { takeUntil, tap } from 'rxjs'
import { Document } from 'src/app/data/document'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { ToastLevel, ToastService } from 'src/app/services/toast.service'
import { TrashService } from 'src/app/services/trash.service'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
@@ -87,9 +87,11 @@ export class TrashComponent
modal.componentInstance.buttonsEnabled.set(false)
this.trashService.emptyTrash([document.id]).subscribe({
next: () => {
this.toastService.showInfo(
$localize`Document "${document.title}" deleted`
)
this.toastService.show({
content: $localize`Document "${document.title}" deleted`,
delay: 5000,
level: ToastLevel.Success,
})
modal.close()
this.reload()
},
@@ -122,7 +124,11 @@ export class TrashComponent
.emptyTrash(documents ? Array.from(documents) : null)
.subscribe({
next: () => {
this.toastService.showInfo($localize`Document(s) deleted`)
this.toastService.show({
content: $localize`Document(s) deleted`,
delay: 5000,
level: ToastLevel.Success,
})
this.allToggled.set(false)
modal.close()
this.reload()
@@ -144,6 +150,7 @@ export class TrashComponent
this.toastService.show({
content: $localize`Document "${document.title}" restored`,
delay: 5000,
level: ToastLevel.Success,
actionName: $localize`Open document`,
action: () => {
this.router.navigate(['documents', document.id])
@@ -165,7 +172,11 @@ export class TrashComponent
.restoreDocuments(documents ? Array.from(documents) : null)
.subscribe({
next: () => {
this.toastService.showInfo($localize`Document(s) restored`)
this.toastService.show({
content: $localize`Document(s) restored`,
delay: 5000,
level: ToastLevel.Success,
})
this.allToggled.set(false)
this.reload()
},
@@ -101,7 +101,7 @@ describe('UsersAndGroupsComponent', () => {
component.editUser(users[0])
const editDialog = modal.componentInstance as UserEditDialogComponent
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
editDialog.failed.emit()
expect(toastErrorSpy).toHaveBeenCalled()
settingsService.currentUser.set(users[1]) // simulate logged in as different user
@@ -120,7 +120,7 @@ describe('UsersAndGroupsComponent', () => {
const deleteDialog = modal.componentInstance as ConfirmDialogComponent
const deleteSpy = jest.spyOn(userService, 'delete')
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const listAllSpy = jest.spyOn(userService, 'listAll')
deleteSpy.mockReturnValueOnce(
throwError(() => new Error('error deleting user'))
@@ -186,7 +186,7 @@ describe('UsersAndGroupsComponent', () => {
component.editGroup(groups[0])
const editDialog = modal.componentInstance as GroupEditDialogComponent
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
editDialog.failed.emit()
expect(toastErrorSpy).toHaveBeenCalled()
editDialog.succeeded.emit(groups[0])
@@ -204,7 +204,7 @@ describe('UsersAndGroupsComponent', () => {
const deleteDialog = modal.componentInstance as ConfirmDialogComponent
const deleteSpy = jest.spyOn(groupService, 'delete')
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const listAllSpy = jest.spyOn(groupService, 'listAll')
deleteSpy.mockReturnValueOnce(
throwError(() => new Error('error deleting group'))
@@ -122,7 +122,7 @@ export class UsersAndGroupsComponent
)
}, 2500)
} else {
this.toastService.showInfo(
this.toastService.showSuccess(
$localize`Saved user "${newUser.username}".`
)
this.usersService.listAll().subscribe((r) => {
@@ -151,7 +151,9 @@ export class UsersAndGroupsComponent
this.usersService.delete(user).subscribe({
next: () => {
modal.close()
this.toastService.showInfo($localize`Deleted user "${user.username}"`)
this.toastService.showSuccess(
$localize`Deleted user "${user.username}"`
)
this.usersService.listAll().subscribe((r) => {
this.users.set(r.results)
})
@@ -178,7 +180,9 @@ export class UsersAndGroupsComponent
modal.componentInstance.succeeded
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((newGroup) => {
this.toastService.showInfo($localize`Saved group "${newGroup.name}".`)
this.toastService.showSuccess(
$localize`Saved group "${newGroup.name}".`
)
this.groupsService.listAll().subscribe((r) => {
this.groups.set(r.results)
})
@@ -204,7 +208,9 @@ export class UsersAndGroupsComponent
this.groupsService.delete(group).subscribe({
next: () => {
modal.close()
this.toastService.showInfo($localize`Deleted group "${group.name}"`)
this.toastService.showSuccess(
$localize`Deleted group "${group.name}"`
)
this.groupsService.listAll().subscribe((r) => {
this.groups.set(r.results)
})
@@ -29,8 +29,13 @@
</div>
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 me-sm-auto order-3 order-sm-1"
[class.mobile-hidden]="mobileSearchHidden()">
<div class="col-12 header-search mx-auto">
<pngx-global-search></pngx-global-search>
<div class="col-12 header-search mx-auto d-flex align-items-stretch gap-3">
<pngx-global-search class="flex-grow-1"></pngx-global-search>
<button *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Document }"
class="btn btn-sm header-upload border-0 d-flex align-items-center justify-content-center flex-shrink-0 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>
</div>
</div>
<ul ngbNav class="order-sm-3">
@@ -70,6 +75,9 @@
</ul>
</nav>
<input type="file" class="visually-hidden" id="navbar-file-upload" (change)="onFileSelected($event)" multiple #fileUpload tabindex="-1" aria-hidden="true">
<label for="navbar-file-upload" class="visually-hidden" i18n>Upload Files</label>
<div class="container-fluid">
<div class="row">
<nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse"
@@ -419,5 +427,6 @@
[ngClass]="slimSidebarEnabled ? 'col-slim' : 'col-sidebar-expanded'">
<router-outlet></router-outlet>
</main>
<pngx-upload-status-tray></pngx-upload-status-tray>
</div>
</div>
@@ -538,7 +538,23 @@ main {
.header-search {
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
&:not(:focus) {
box-shadow: 0 1px 3px rgba(0, 0, 0, .35), 0 1px 1px rgba(0, 0, 0, .2); // leave room for the .btn:focus ring
}
}
@media screen and (max-width: 575.98px) {
@@ -630,8 +646,7 @@ main {
}
.dropdown-toggle::after {
margin-left: 0.4em;
vertical-align: 0.155em;
display: none;
}
.navbar .dropdown-menu {
@@ -668,3 +683,7 @@ main {
::ng-deep .navItemDrag .position-absolute i-bs {
display: none;
}
.smaller {
font-size: 0.75rem;
}
@@ -33,6 +33,7 @@ import { SearchService } from 'src/app/services/rest/search.service'
import { SettingsService } from 'src/app/services/settings.service'
import { TasksService } from 'src/app/services/tasks.service'
import { ToastService } from 'src/app/services/toast.service'
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
import { environment } from 'src/environments/environment'
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
@@ -462,6 +463,29 @@ describe('AppFrameComponent', () => {
expect(routerSpy).toHaveBeenCalled()
})
it('should upload files selected from the navbar upload button', () => {
const uploadDocumentsService = TestBed.inject(UploadDocumentsService)
const uploadSpy = jest
.spyOn(uploadDocumentsService, 'uploadFiles')
.mockImplementation(() => {})
const toastSpy = jest.spyOn(toastService, 'showInfo')
const file = new File(['testing'], 'file.pdf')
const input = window.document.createElement('input')
jest.spyOn(input, 'files', 'get').mockReturnValue([file] as any)
component.onFileSelected({ target: input } as unknown as Event)
expect(toastSpy).toHaveBeenCalled()
expect(uploadSpy).toHaveBeenCalledWith([file])
})
it('should not upload when no files are selected from the navbar', () => {
const uploadDocumentsService = TestBed.inject(UploadDocumentsService)
const uploadSpy = jest.spyOn(uploadDocumentsService, 'uploadFiles')
const input = window.document.createElement('input')
component.onFileSelected({ target: input } as unknown as Event)
expect(uploadSpy).not.toHaveBeenCalled()
})
it('should close all documents on logout', () => {
const closeAllSpy = jest.spyOn(openDocumentsService, 'closeAll')
component.onLogout()
@@ -483,7 +507,7 @@ describe('AppFrameComponent', () => {
it('should update saved view sorting on drag + drop, show info', () => {
const settingsSpy = jest.spyOn(settingsService, 'updateSidebarViewsSort')
const toastSpy = jest.spyOn(toastService, 'showInfo')
const toastSpy = jest.spyOn(toastService, 'showSuccess')
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
component.onDrop({ previousIndex: 0, currentIndex: 1 } as CdkDragDrop<
SavedView[]
@@ -525,6 +549,7 @@ describe('AppFrameComponent', () => {
it('should show toasts for django messages', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
jest.spyOn(messagesService, 'get').mockReturnValue([
{ level: DjangoMessageLevel.WARNING, message: 'Test warning' },
@@ -535,7 +560,8 @@ describe('AppFrameComponent', () => {
])
component.ngOnInit()
expect(toastErrorSpy).toHaveBeenCalledTimes(2)
expect(toastInfoSpy).toHaveBeenCalledTimes(3)
expect(toastSuccessSpy).toHaveBeenCalledTimes(1)
expect(toastInfoSpy).toHaveBeenCalledTimes(2)
})
it('should call maybeRefreshDocumentCounts after saved views reload', () => {
@@ -48,12 +48,14 @@ import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SettingsService } from 'src/app/services/settings.service'
import { TasksService } from 'src/app/services/tasks.service'
import { ToastService } from 'src/app/services/toast.service'
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
import { environment } from 'src/environments/environment'
import { ChatComponent } from '../chat/chat/chat.component'
import { SwitchComponent } from '../common/input/switch/switch.component'
import { BrandMarkComponent } from '../common/logo/brand-mark/brand-mark.component'
import { LogoComponent } from '../common/logo/logo.component'
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
import { 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 { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
import { GlobalSearchComponent } from './global-search/global-search.component'
@@ -84,6 +86,7 @@ const SCROLL_THRESHOLD = 16
TourNgBootstrap,
FormsModule,
SwitchComponent,
UploadStatusTrayComponent,
],
})
export class AppFrameComponent
@@ -101,6 +104,7 @@ export class AppFrameComponent
private modalService = inject(NgbModal)
permissionsService = inject(PermissionsService)
private djangoMessagesService = inject(DjangoMessagesService)
private uploadDocumentsService = inject(UploadDocumentsService)
readonly appRemoteVersion = signal<AppRemoteVersion>(null)
readonly isMenuCollapsed = signal(true)
@@ -171,6 +175,8 @@ export class AppFrameComponent
this.toastService.showError(message.message)
break
case DjangoMessageLevel.SUCCESS:
this.toastService.showSuccess(message.message)
break
case DjangoMessageLevel.INFO:
case DjangoMessageLevel.DEBUG:
this.toastService.showInfo(message.message)
@@ -477,7 +483,7 @@ export class AppFrameComponent
this.settingsService.updateSidebarViewsSort(sidebarViews).subscribe({
next: () => {
this.toastService.showInfo($localize`Sidebar views updated`)
this.toastService.showSuccess($localize`Sidebar views updated`)
},
error: (e) => {
this.toastService.showError($localize`Error updating sidebar views`, e)
@@ -511,6 +517,15 @@ 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.toastService.showInfo($localize`Initiating upload...`, 3000)
this.uploadDocumentsService.uploadFiles(files)
}
onLogout() {
this.openDocumentsService.closeAll()
}
@@ -409,7 +409,7 @@ describe('GlobalSearchComponent', () => {
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
// fail first
editDialog.failed.emit({ error: 'error creating item' })
@@ -460,7 +460,7 @@ describe('GlobalSearchComponent', () => {
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
// fail first
editDialog.failed.emit({ error: 'error creating item' })
@@ -216,7 +216,7 @@ export class GlobalSearchComponent implements OnInit {
modalRef.componentInstance.dialogMode.set(EditDialogMode.EDIT)
modalRef.componentInstance.object = object
modalRef.componentInstance.succeeded.subscribe(() => {
this.toastService.showInfo($localize`Successfully updated object.`)
this.toastService.showSuccess($localize`Successfully updated object.`)
})
modalRef.componentInstance.failed.subscribe((e) => {
this.toastService.showError($localize`Error occurred saving object.`, e)
@@ -254,7 +254,7 @@ export class GlobalSearchComponent implements OnInit {
modalRef.componentInstance.dialogMode.set(EditDialogMode.EDIT)
modalRef.componentInstance.object = object
modalRef.componentInstance.succeeded.subscribe(() => {
this.toastService.showInfo($localize`Successfully updated object.`)
this.toastService.showSuccess($localize`Successfully updated object.`)
})
modalRef.componentInstance.failed.subscribe((e) => {
this.toastService.showError($localize`Error occurred saving object.`, e)
@@ -1,28 +1,31 @@
<li ngbDropdown class="nav-item position-relative" (openChange)="onOpenChange($event)">
@if (toasts().length) {
<span class="notification-count badge rounded-pill z-3 pe-none bg-secondary position-absolute">{{ toasts().length }}</span>
@if (unread().length) {
<span class="notification-count badge rounded-pill z-3 pe-none position-absolute" [class.bg-danger]="hasUnreadError()" [class.bg-secondary]="!hasUnreadError()">{{ unread().length }}</span>
}
<button class="btn navbar-action border-0" id="notificationsDropdown" ngbDropdownToggle aria-label="Notifications" i18n-aria-label>
<i-bs width="1.3em" height="1.3em" name="bell"></i-bs>
</button>
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="notificationsDropdown">
<div class="btn-toolbar align-items-center" role="toolbar">
<h6 i18n>Notifications</h6>
<div class="btn-group ms-auto">
<button class="btn btn-sm btn-outline-secondary mb-2 ms-auto"
(click)="toastService.clearToasts()"
[disabled]="toasts().length === 0"
i18n>Clear All</button>
</div>
<div ngbDropdownMenu class="dropdown-menu-end shadow p-2" aria-labelledby="notificationsDropdown">
<div class="d-flex align-items-center px-1 mb-2">
<h6 class="mb-0" i18n>Notifications</h6>
@if (toasts().length) {
<button class="btn btn-link btn-sm p-0 ms-auto text-body-secondary text-decoration-none clear-all"
(click)="toastService.clearToasts()">
<i-bs width="1em" height="1em" name="check2-all" class="me-1"></i-bs><ng-container i18n>Clear All</ng-container>
</button>
}
</div>
@if (toasts().length === 0) {
<p class="text-center mb-0 small text-muted"><em i18n>No notifications</em></p>
}
<div class="scroll-list">
@for (toast of toasts(); track toast.id) {
<pngx-toast [autohide]="false" [toast]="toast" (hidden)="onHidden(toast)" (closed)="toastService.closeToast(toast)"></pngx-toast>
}
<div class="text-center text-body-secondary small py-4">
<i-bs width="1.5em" height="1.5em" name="bell" class="d-block mx-auto mb-2 opacity-50"></i-bs>
<ng-container i18n>No notifications</ng-container>
</div>
}
<div class="scroll-list d-flex flex-column gap-2">
@for (toast of toasts(); track toast.id) {
<pngx-toast [autohide]="false" [toast]="toast" [now]="now()" [isNew]="newIds().has(toast.id)" (closed)="toastService.closeToast(toast)" (actioned)="onActioned(toast)"></pngx-toast>
}
</div>
</div>
</li>
@@ -30,3 +30,7 @@
right: -3rem;
}
}
.clear-all:hover {
color: var(--bs-body-color) !important;
}
@@ -3,7 +3,7 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { Subject } from 'rxjs'
import { Toast, ToastService } from 'src/app/services/toast.service'
import { Toast, ToastLevel, ToastService } from 'src/app/services/toast.service'
import { ToastsDropdownComponent } from './toasts-dropdown.component'
const toasts = [
@@ -90,4 +90,73 @@ describe('ToastsDropdownComponent', () => {
component.onOpenChange(true)
expect(spy).toHaveBeenCalledWith(true)
})
it('should badge unread toasts, red when one is an error', () => {
toastsSubject.next([
{ id: 'a', content: 'Read', delay: 5000, read: true },
{ id: 'b', content: 'Unread', delay: 5000, read: false },
])
fixture.detectChanges()
let badge: HTMLElement = fixture.nativeElement.querySelector(
'.notification-count'
)
expect(badge.textContent.trim()).toEqual('1')
expect(badge.classList).toContain('bg-secondary')
toastsSubject.next([
{ id: 'c', content: 'Failed', delay: 5000, level: ToastLevel.Error },
])
fixture.detectChanges()
badge = fixture.nativeElement.querySelector('.notification-count')
expect(badge.classList).toContain('bg-danger')
toastsSubject.next([{ id: 'd', content: 'Seen', delay: 5000, read: true }])
fixture.detectChanges()
expect(
fixture.nativeElement.querySelector('.notification-count')
).toBeNull()
})
it('should mark all toasts read when opened', () => {
const markSpy = jest.spyOn(toastService, 'markAllRead')
component.onOpenChange(false)
expect(markSpy).not.toHaveBeenCalled()
component.onOpenChange(true)
expect(markSpy).toHaveBeenCalled()
})
it('should tick the clock only while open', () => {
jest.useFakeTimers()
component.onOpenChange(true)
const opened = component.now()
jest.advanceTimersByTime(30000)
const ticked = component.now()
expect(ticked).not.toBe(opened)
component.onOpenChange(false)
jest.advanceTimersByTime(60000)
expect(component.now()).toBe(ticked)
jest.useRealTimers()
})
it('should mark toasts that were unread when opened as new', () => {
toastsSubject.next([
{ id: 'a', content: 'Seen', delay: 5000, read: true },
{ id: 'b', content: 'Unseen', delay: 5000, read: false },
])
fixture.detectChanges()
component.onOpenChange(true)
expect([...component.newIds()]).toEqual(['b'])
component.onOpenChange(false)
expect(component.newIds().size).toEqual(0)
})
it('should clear the toast and close the menu when an action is used', () => {
const toast = { id: 'a', content: 'Moved', delay: 5000 }
const closeToastSpy = jest.spyOn(toastService, 'closeToast')
const closeSpy = jest.spyOn(component.dropdown, 'close')
component.onActioned(toast)
expect(closeToastSpy).toHaveBeenCalledWith(toast)
expect(closeSpy).toHaveBeenCalled()
})
})
@@ -1,12 +1,20 @@
import { Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import {
Component,
DestroyRef,
ViewChild,
computed,
inject,
signal,
} from '@angular/core'
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'
import {
NgbDropdown,
NgbDropdownModule,
NgbProgressbarModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { map } from 'rxjs'
import { Toast, ToastService } from 'src/app/services/toast.service'
import { Subscription, interval, map } from 'rxjs'
import { Toast, ToastLevel, ToastService } from 'src/app/services/toast.service'
import { ToastComponent } from '../../common/toast/toast.component'
@Component({
@@ -22,13 +30,44 @@ import { ToastComponent } from '../../common/toast/toast.component'
})
export class ToastsDropdownComponent {
toastService = inject(ToastService)
private destroyRef = inject(DestroyRef)
private clock: Subscription
@ViewChild(NgbDropdown) dropdown: NgbDropdown
/** Drives the relative timestamps, ticks only while the dropdown is open */
readonly now = signal(new Date())
/** Notifications that were unread when the dropdown was opened */
readonly newIds = signal(new Set<string>())
readonly toasts = toSignal(
this.toastService.getToasts().pipe(map((toasts) => [...toasts])),
{ initialValue: [] as Toast[] }
)
readonly unread = computed(() => this.toasts().filter((t) => !t.read))
readonly hasUnreadError = computed(() =>
this.unread().some((t) => t.level === ToastLevel.Error)
)
onOpenChange(open: boolean): void {
this.toastService.suppressPopupToasts = open
this.clock?.unsubscribe()
this.newIds.set(new Set(open ? this.unread().map((t) => t.id) : []))
if (open) {
this.toastService.markAllRead()
this.now.set(new Date())
this.clock = interval(30000)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.now.set(new Date()))
}
}
/** Using an action (Undo, Open document...) clears it and closes the menu */
onActioned(toast: Toast): void {
this.toastService.closeToast(toast)
this.dropdown?.close()
}
}
@@ -1,49 +1,85 @@
<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>
<i-bs width="1.3em" height="1.3em" name="chatSquareDots"></i-bs>
</button>
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown">
<div class="chat-container bg-light p-2">
<div class="chat-messages font-monospace small">
@for (message of messages(); track message) {
<div class="message d-flex flex-row small" [class.justify-content-end]="message.role === 'user'">
<div class="p-2 m-2" [class.bg-body]="message.role === 'user'">
<span class="text-break">
{{ message.content }}
@if (message.isStreaming) { <span class="blinking-cursor">|</span> }
</span>
@if (message.role === 'assistant' && message.references?.length) {
<div class="chat-references list-group mt-3">
@for (reference of message.references; track reference.id) {
<a class="list-group-item list-group-item-action d-flex text-primary text-break" [routerLink]="['/documents', reference.id]">
<i-bs width="0.9em" height="0.9em" name="file-text" class="me-1"></i-bs><span>{{ reference.title }}</span>
</a>
}
</div>
}
</div>
</div>
<div ngbDropdownMenu class="dropdown-menu-end shadow p-0 overflow-hidden" aria-labelledby="chatDropdown">
<div class="d-flex align-items-center border-bottom px-3 py-2">
<i-bs width="1em" height="1em" name="stars" class="text-primary me-2"></i-bs>
<span class="fw-semibold small">
@if (documentId()) {
<ng-container i18n>Ask about this document</ng-container>
} @else {
<ng-container i18n>Ask about your documents</ng-container>
}
<div #scrollAnchor></div>
</div>
</span>
@if (messages().length) {
<button class="btn btn-sm btn-link text-muted ms-auto p-0" type="button" (click)="clearChat()" title="Clear chat" i18n-title>
<i-bs width="1em" height="1em" name="arrow-counterclockwise"></i-bs>
</button>
}
</div>
<form class="chat-input">
<label class="visually-hidden" for="chatInput" i18n>Message</label>
<div class="input-group">
<input
#chatInput
id="chatInput"
class="form-control form-control-sm" name="chatInput" type="text"
<div class="chat-messages small px-2">
@if (!messages().length) {
<p class="text-muted text-center py-4 px-3 mb-0" i18n>Answers come from your documents, with links to their sources.</p>
}
@for (message of messages(); track message) {
<div class="message d-flex flex-row" [class.justify-content-end]="message.role === 'user'" [class.mt-3]="message.role === 'user' && !$first">
<div class="p-2 m-2" [class.bg-body-secondary]="message.role === 'user'" [class.rounded-3]="message.role === 'user'" [class.flex-grow-1]="message.role === 'assistant'">
@if (message.isStreaming && !message.content) {
<div class="d-flex align-items-center text-muted">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
@if (slowResponse()) {
<ng-container i18n>Still thinking...</ng-container>
} @else {
<ng-container i18n>Thinking...</ng-container>
}
</div>
} @else if (message.role === 'assistant') {
<div class="chat-markdown text-break text-wrap" [class.streaming]="message.isStreaming" [innerHTML]="message.content | markdown"></div>
} @else {
<span class="text-break">{{ message.content }}</span>
}
@if (message.role === 'assistant' && !message.isStreaming && message.references?.length) {
<div class="chat-references list-group mt-3">
<div class="list-group-item text-muted small py-1" i18n>Sources</div>
@for (reference of message.references; track reference.id) {
<a class="list-group-item list-group-item-action d-flex align-items-center text-primary" [routerLink]="['/documents', reference.id]" [title]="reference.title">
<i-bs width="0.9em" height="0.9em" name="file-text" class="me-2 flex-shrink-0"></i-bs><span class="text-truncate">{{ reference.title }}</span>
</a>
}
</div>
}
</div>
</div>
}
<div #scrollAnchor></div>
</div>
<form class="chat-input border-top p-2">
<label class="visually-hidden" for="chatInput" i18n>Message</label>
<div class="input-group">
<input
#chatInput
id="chatInput"
class="form-control" name="chatInput" type="text"
[placeholder]="placeholder"
[disabled]="loading()"
[ngModel]="input()"
(ngModelChange)="input.set($event)"
(keydown)="searchInputKeyDown($event)"
/>
<button class="btn btn-sm btn-secondary" type="button" (click)="sendMessage()" [disabled]="loading()">Send</button>
</div>
</form>
</div>
/>
@if (loading()) {
<button class="btn btn-outline-secondary" type="button" (click)="stopStreaming()" title="Stop" i18n-title aria-label="Stop" i18n-aria-label>
<i-bs width="1em" height="1em" name="x-lg"></i-bs>
</button>
} @else {
<button class="btn btn-primary" type="button" (click)="sendMessage()" title="Send" i18n-title aria-label="Send" i18n-aria-label>
<i-bs width="1em" height="1em" name="arrow-return-left"></i-bs>
</button>
}
</div>
</form>
</div>
</li>
@@ -1,17 +1,13 @@
.dropdown-menu {
width: var(--pngx-toast-max-width);
width: min(36rem, calc(100vw - 20px));
}
.chat-messages {
max-height: 350px;
max-height: min(60dvh, 36rem);
overflow-y: auto;
white-space: pre-wrap;
}
.chat-references {
font-family: var(--bs-font-sans-serif);
}
.dropdown-toggle::after {
display: none;
}
@@ -20,23 +16,44 @@
white-space: initial;
}
@media screen and (max-width: 400px) {
:host ::ng-deep .dropdown-menu-end {
right: -3rem;
@media screen and (max-width: 575.98px) {
.chat-messages {
max-height: calc(100dvh - 12rem);
}
}
.blinking-cursor {
font-weight: bold;
font-size: 1.2em;
animation: blink 1s step-end infinite;
.chat-markdown.streaming ::ng-deep {
> :last-child:not(ul, ol, table)::after,
> :is(ul, ol):last-child > li:last-child::after {
content: '';
display: inline-block;
width: 2px;
height: 1.05em;
margin-left: 2px;
background-color: currentColor;
vertical-align: -0.15em;
animation: blink 1s step-end infinite;
}
}
@keyframes blink {
from, to {
50% {
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.loading()).toBe(true)
fixture.detectChanges()
const messagesEl = fixture.nativeElement.querySelector('.chat-messages')
expect(messagesEl.querySelector('.spinner-border')).not.toBeNull()
expect(messagesEl.textContent).toContain('Thinking...')
mockStream$.next('Hi')
expect(component.messages()[1].content).toBe('H')
fixture.detectChanges()
expect(messagesEl.querySelector('.spinner-border')).toBeNull()
expect(messagesEl.querySelector('.chat-markdown.streaming')).not.toBeNull()
mockStream$.next('Hi there')
// advance time to process the typewriter effect
await jest.runAllTimersAsync()
@@ -96,6 +104,8 @@ describe('ChatComponent', () => {
await fixture.whenStable()
expect(component.loading()).toBe(false)
expect(component.messages()[1].isStreaming).toBe(false)
fixture.detectChanges()
expect(messagesEl.querySelector('.chat-markdown.streaming')).toBeNull()
expect(fixture.nativeElement.querySelector('#chatInput').disabled).toBe(
false
)
@@ -123,6 +133,10 @@ describe('ChatComponent', () => {
mockStream$.next(
`Hi there${CHAT_METADATA_DELIMITER}{"references":[{"id":42,"title":"Bread Recipe"}]}`
)
fixture.detectChanges()
expect(fixture.nativeElement.querySelector('.chat-references')).toBeNull()
mockStream$.complete()
jest.advanceTimersByTime(1000)
fixture.detectChanges()
@@ -156,13 +170,96 @@ describe('ChatComponent', () => {
component.input.set('Hello')
component.sendMessage()
mockStream$.next('Partial')
mockStream$.error('Error')
expect(component.messages()[1].content).toContain(
'⚠️ Error receiving response.'
expect(component.messages()[1].content).toBe(
'Partial\n\n⚠️ Error receiving response.'
)
expect(component.loading()).toBe(false)
})
it('should keep streaming until the typewriter catches up', () => {
component.input.set('Hello')
component.sendMessage()
mockStream$.next('Hi there')
mockStream$.complete()
expect(component.messages()[1].isStreaming).toBe(true)
expect(component.loading()).toBe(true)
jest.advanceTimersByTime(1000)
expect(component.messages()[1].content).toBe('Hi there')
expect(component.messages()[1].isStreaming).toBe(false)
expect(component.loading()).toBe(false)
})
it('should show a slow response message while waiting', () => {
component.input.set('Hello')
component.sendMessage()
fixture.detectChanges()
const messagesEl = fixture.nativeElement.querySelector('.chat-messages')
expect(messagesEl.textContent).not.toContain('Still thinking...')
jest.advanceTimersByTime(8000)
fixture.detectChanges()
expect(messagesEl.textContent).toContain('Still thinking...')
mockStream$.next('Hi')
expect(component.slowResponse()).toBe(false)
})
it('should stop streaming and keep the partial response', () => {
component.input.set('Hello')
component.sendMessage()
fixture.detectChanges()
const stopButton = fixture.nativeElement.querySelector('.chat-input button')
expect(stopButton.getAttribute('aria-label')).toBe('Stop')
mockStream$.next('Hi there')
jest.advanceTimersByTime(20)
stopButton.click()
expect(mockStream$.observed).toBe(false)
expect(component.messages()[1].content).toBe('Hi there')
expect(component.messages()[1].isStreaming).toBe(false)
expect(component.loading()).toBe(false)
jest.advanceTimersByTime(1000)
expect(component.messages()[1].content).toBe('Hi there')
})
it('should drop the empty response when stopped before any content', () => {
component.input.set('Hello')
component.sendMessage()
component.stopStreaming()
expect(component.messages()).toHaveLength(1)
expect(component.loading()).toBe(false)
component.stopStreaming() // no-op once stopped
expect(component.messages()).toHaveLength(1)
})
it('should clear the conversation', () => {
component.input.set('Hello')
component.sendMessage()
mockStream$.next('Hi there')
fixture.detectChanges()
fixture.nativeElement.querySelector('button[title="Clear chat"]').click()
expect(component.messages()).toHaveLength(0)
expect(component.loading()).toBe(false)
expect(mockStream$.observed).toBe(false)
fixture.detectChanges()
jest.advanceTimersByTime(10)
expect(document.activeElement).toBe(
fixture.nativeElement.querySelector('#chatInput')
)
expect(fixture.nativeElement.textContent).toContain(
'Answers come from your documents'
)
})
it('should enqueue typewriter chunks correctly', () => {
const message = { content: '', role: 'assistant', isStreaming: true }
component.enqueueTypewriter(null, message as any) // coverage for null
@@ -207,4 +304,33 @@ describe('ChatComponent', () => {
component.searchInputKeyDown(event)
expect(component.sendMessage).not.toHaveBeenCalled()
})
it('should render markdown for assistant messages only', () => {
component.messages.set([
{ role: 'user', content: '**user**' },
{ role: 'assistant', content: '**assistant** <script>x</script>' },
])
fixture.detectChanges()
const el: HTMLElement = fixture.nativeElement
expect(el.querySelector('.chat-markdown strong')?.textContent).toBe(
'assistant'
)
expect(el.querySelector('.chat-markdown script')).toBeNull()
expect(el.textContent).toContain('**user**')
})
it('should keep the destination hostname visible after sanitizing chat links', () => {
component.messages.set([
{
role: 'assistant',
content:
'[View original document](https://otherdomain.example/?data=secret)',
},
])
fixture.detectChanges()
const el: HTMLElement = fixture.nativeElement
const link = el.querySelector('.chat-markdown a')
expect(link?.textContent).toBe('View original document')
expect(link?.nextElementSibling?.textContent).toBe('(otherdomain.example)')
})
})
@@ -10,12 +10,16 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NavigationEnd, Router, RouterModule } from '@angular/router'
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { filter, map } from 'rxjs'
import { filter, map, Subscription } from 'rxjs'
import { MarkdownPipe } from 'src/app/pipes/markdown.pipe'
import {
ChatMessage,
ChatService,
parseChatResponse,
} from 'src/app/services/chat.service'
import { pngxPopperOptions } from 'src/app/utils/popper-options'
const SLOW_RESPONSE_DELAY_MS = 8000
@Component({
selector: 'pngx-chat',
@@ -25,6 +29,7 @@ import {
RouterModule,
NgxBootstrapIconsModule,
NgbDropdownModule,
MarkdownPipe,
],
templateUrl: './chat.component.html',
styleUrl: './chat.component.scss',
@@ -34,6 +39,8 @@ export class ChatComponent implements OnInit {
readonly loading = signal(false)
readonly input = signal('')
readonly documentId = signal<number>(undefined)
readonly slowResponse = signal(false)
public popperOptions = pngxPopperOptions
private chatService: ChatService = inject(ChatService)
private router: Router = inject(Router)
@@ -43,6 +50,11 @@ export class ChatComponent implements OnInit {
private typewriterBuffer: string[] = []
private typewriterActive = false
private typewriterTimeout: ReturnType<typeof setTimeout>
private streamSubscription: Subscription
private streamingMessage: ChatMessage
private streamFinished = false
private slowResponseTimeout: ReturnType<typeof setTimeout>
public get placeholder(): string {
return this.documentId()
@@ -81,47 +93,96 @@ export class ChatComponent implements OnInit {
}
this.messages.update((messages) => [...messages, assistantMessage])
this.loading.set(true)
this.streamingMessage = assistantMessage
this.streamFinished = false
this.slowResponseTimeout = setTimeout(
() => this.slowResponse.set(true),
SLOW_RESPONSE_DELAY_MS
)
let lastVisibleContent = ''
this.chatService.streamChat(this.documentId(), this.input()).subscribe({
next: (chunk) => {
const nextResponse = parseChatResponse(chunk)
this.streamSubscription = this.chatService
.streamChat(this.documentId(), this.input())
.subscribe({
next: (chunk) => {
this.clearSlowResponse()
const nextResponse = parseChatResponse(chunk)
if (nextResponse.content.length < lastVisibleContent.length) {
this.resetTypewriter(assistantMessage, nextResponse.content)
lastVisibleContent = nextResponse.content
} else {
const visibleDelta = nextResponse.content.substring(
lastVisibleContent.length
)
lastVisibleContent = nextResponse.content
this.enqueueTypewriter(visibleDelta, assistantMessage)
}
if (nextResponse.content.length < lastVisibleContent.length) {
this.resetTypewriter(assistantMessage, nextResponse.content)
lastVisibleContent = nextResponse.content
} else {
const visibleDelta = nextResponse.content.substring(
lastVisibleContent.length
)
lastVisibleContent = nextResponse.content
this.enqueueTypewriter(visibleDelta, assistantMessage)
}
assistantMessage.references = nextResponse.references
this.notifyMessagesChanged()
},
error: () => {
assistantMessage.content += '\n\n⚠️ Error receiving response.'
assistantMessage.isStreaming = false
this.notifyMessagesChanged()
this.loading.set(false)
},
complete: () => {
assistantMessage.isStreaming = false
this.notifyMessagesChanged()
this.loading.set(false)
this.scrollToBottom()
},
})
assistantMessage.references = nextResponse.references
this.notifyMessagesChanged()
},
error: () => {
this.flushTypewriter(assistantMessage)
assistantMessage.content += '\n\n⚠️ Error receiving response.'
this.finishStreaming(assistantMessage)
},
complete: () => {
// let the typewriter catch up before hiding the cursor
this.streamFinished = true
if (!this.typewriterActive) this.finishStreaming(assistantMessage)
},
})
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.typewriterActive = false
}
private resetTypewriter(message: ChatMessage, content: string): void {
this.cancelTypewriter()
message.content = content
this.notifyMessagesChanged()
this.scrollToBottom()
@@ -141,6 +202,7 @@ export class ChatComponent implements OnInit {
playTypewriter(message: ChatMessage): void {
if (this.typewriterBuffer.length === 0) {
this.typewriterActive = false
if (this.streamFinished) this.finishStreaming(message)
return
}
@@ -149,7 +211,8 @@ export class ChatComponent implements OnInit {
this.notifyMessagesChanged()
this.scrollToBottom()
setTimeout(() => this.playTypewriter(message), 10) // 10ms per character
// 10ms per character
this.typewriterTimeout = setTimeout(() => this.playTypewriter(message), 10)
}
private notifyMessagesChanged(): void {
@@ -110,7 +110,7 @@ describe('CustomFieldsDropdownComponent', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const getFieldsSpy = jest.spyOn(
CustomFieldsDropdownComponent.prototype as any,
'getFields'
@@ -129,7 +129,9 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio
modal.componentInstance.succeeded
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((newField) => {
this.toastService.showInfo($localize`Saved field "${newField.name}".`)
this.toastService.showSuccess(
$localize`Saved field "${newField.name}".`
)
this.customFieldsService.clearCache()
this.getFields()
this.created.emit(newField)
@@ -151,7 +151,7 @@ describe('UserEditDialogComponent', () => {
'deactivateTotp'
)
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
deactivateSpy.mockReturnValueOnce(throwError(() => new Error('error')))
component.deactivateTotp()
expect(deactivateSpy).toHaveBeenCalled()
@@ -132,7 +132,7 @@ export class UserEditDialogComponent
next: (result) => {
this.totpLoading.set(false)
if (result) {
this.toastService.showInfo($localize`Totp deactivated`)
this.toastService.showSuccess($localize`Totp deactivated`)
this.object.is_mfa_enabled = false
} else {
this.toastService.showError($localize`Totp deactivation failed`)
@@ -209,6 +209,7 @@
@if (isTagsFilter(filter.get('type').value)) {
<pngx-input-tags
[allowCreate]="false"
[preserveHierarchy]="false"
[title]="null"
formControlName="values"
></pngx-input-tags>
@@ -320,7 +321,7 @@
<h6 class="form-label" i18n>Remove tags</h6>
<pngx-input-switch i18n-title title="Remove all" [horizontal]="true" formControlName="remove_all_tags"></pngx-input-switch>
<div class="mt-n3">
<pngx-input-tags [allowCreate]="false" title="" formControlName="remove_tags"></pngx-input-tags>
<pngx-input-tags [allowCreate]="false" [preserveHierarchy]="false" title="" formControlName="remove_tags"></pngx-input-tags>
</div>
<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', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastSuccessSpy = jest.spyOn(toastService, 'showInfo')
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
component.documentIds.set([1])
component.emailAddress = 'hello@paperless-ngx.com'
component.emailSubject = 'Hello'
@@ -73,7 +73,7 @@ describe('EmailDocumentDialogComponent', () => {
it('should support sending multiple documents via email, showing appropriate messages', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastSuccessSpy = jest.spyOn(toastService, 'showInfo')
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
component.documentIds.set([1, 2, 3])
component.emailAddress = 'hello@paperless-ngx.com'
component.emailSubject = 'Hello'
@@ -50,7 +50,7 @@ export class EmailDocumentDialogComponent extends LoadingComponentWithPermission
this.emailSubject = ''
this.emailMessage = ''
this.close()
this.toastService.showInfo($localize`Email sent`)
this.toastService.showSuccess($localize`Email sent`)
},
error: (e) => {
this.loading.set(false)
@@ -1,5 +1,7 @@
@if (!accordion) {
<h5 i18n>Permissions</h5>
@if (title?.length > 0) {
<h5>{{title}}</h5>
}
<ng-container [ngTemplateOutlet]="permissionsForm"></ng-container>
}
@if (accordion) {
@@ -59,6 +59,9 @@ export class PermissionsFormComponent
@Input()
accordion: boolean = false
@Input()
title: string = $localize`Permissions`
form = new FormGroup({
owner: new FormControl(null),
set_permissions: new FormGroup({
@@ -222,6 +222,22 @@ describe('TagsComponent', () => {
expect(propagated).toEqual([2, 1])
})
it('should not add parents or remove children when not preserving hierarchy', () => {
const parent: Tag = { id: 1, name: 'parent' }
const child: Tag = { id: 2, name: 'child', parent: 1 }
parent.children = [child]
component.tags = [parent, child]
component.preserveHierarchy = false
component.value = []
component.addTag(2)
expect(component.value).toEqual([2])
component.value = [1, 2]
component.removeTag(1)
expect(component.value).toEqual([2])
})
it('should not duplicate parents when adding sibling nested tags', () => {
const root: Tag = { id: 1, name: 'root' }
const parent: Tag = { id: 2, name: 'parent', parent: 1 }
@@ -109,6 +109,9 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
@Input()
multiple: boolean = true
@Input()
preserveHierarchy: boolean = true
@Output()
filterDocuments = new EventEmitter<Tag[]>()
@@ -148,7 +151,9 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
oldValue.splice(index, 1)
// remove children
oldValue = this.removeChildren(oldValue, tag)
if (this.preserveHierarchy) {
oldValue = this.removeChildren(oldValue, tag)
}
this.value = [...oldValue]
this.onChange(this.value)
@@ -167,7 +172,7 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
}
public onAdd(tag: Tag) {
if (tag?.parent) {
if (this.preserveHierarchy && tag?.parent) {
// add all parents recursively
const parent = this.getTag(tag.parent)
if (parent && !this.value.includes(parent.id)) {
@@ -3,13 +3,13 @@
<h3 class="d-flex align-items-center mb-2 mb-md-1" style="line-height: 1.4">
<span class="text-truncate">{{title()}}</span>
@if (id()) {
<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()) {
<i-bs width="1em" height="1em" name="clipboard-check" class="me-1"></i-bs><ng-container i18n>Copied!</ng-container>
} @else {
ID: {{id()}}
}
</span>
</button>
}
@if (subTitle()) {
<span class="page-subtitle h6 mb-0 mt-1 fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle()}}</span>
@@ -43,6 +43,13 @@ describe('PageHeaderComponent', () => {
expect(titleSpy).toHaveBeenCalledWith(`Foo Bar - ${environment.appTitle}`)
})
it('should set html title to just the app title until a title is available', () => {
const titleSpy = jest.spyOn(titleService, 'setTitle')
fixture.componentRef.setInput('title', undefined)
fixture.detectChanges()
expect(titleSpy).toHaveBeenCalledWith(environment.appTitle)
})
it('should copy id to clipboard, reset after 3 seconds', () => {
jest.useFakeTimers()
fixture.componentRef.setInput('id', 42)
@@ -27,7 +27,10 @@ export class PageHeaderComponent {
constructor() {
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()
updateSpy.mockClear()
const infoSpy = jest.spyOn(toastService, 'showInfo')
const infoSpy = jest.spyOn(toastService, 'showSuccess')
component.form.patchValue(newProfile)
updateSpy.mockReturnValueOnce(of(newProfile))
component.save()
@@ -373,7 +373,7 @@ describe('ProfileEditDialogComponent', () => {
it('should activate totp', () => {
const activateSpy = jest.spyOn(profileService, 'activateTotp')
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const error = new Error('failed to activate totp')
activateSpy.mockReturnValueOnce(throwError(() => error))
component.totpSettings.set({
@@ -405,7 +405,7 @@ describe('ProfileEditDialogComponent', () => {
it('should deactivate totp', () => {
const deactivateSpy = jest.spyOn(profileService, 'deactivateTotp')
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const error = new Error('failed to deactivate totp')
deactivateSpy.mockReturnValueOnce(throwError(() => error))
component.deactivateTotp()
@@ -204,7 +204,7 @@ export class ProfileEditDialogComponent
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({
next: () => {
this.toastService.showInfo($localize`Profile updated successfully`)
this.toastService.showSuccess($localize`Profile updated successfully`)
if (passwordChanged) {
this.toastService.showInfo(
$localize`Password has been changed, you will be logged out momentarily.`
@@ -307,7 +307,9 @@ export class ProfileEditDialogComponent
this.recoveryCodes.set(activationResponse.recovery_codes)
this.form.get('totp_code').enable()
if (activationResponse.success) {
this.toastService.showInfo($localize`TOTP activated successfully`)
this.toastService.showSuccess(
$localize`TOTP activated successfully`
)
} else {
this.toastService.showError($localize`Error activating TOTP`)
}
@@ -331,7 +333,9 @@ export class ProfileEditDialogComponent
this.isTotpEnabled.set(!success)
this.recoveryCodes.set(null)
if (success) {
this.toastService.showInfo($localize`TOTP deactivated successfully`)
this.toastService.showSuccess(
$localize`TOTP deactivated successfully`
)
} else {
this.toastService.showError($localize`Error deactivating TOTP`)
}
@@ -1,22 +1,33 @@
<ngb-toast
[autohide]="autohide"
[delay]="toast.delay"
[class]="toast.classname"
[class.mb-2]="true"
[autohide]="false"
[class.mb-2]="autohide"
[class.toast-list-item]="!autohide"
[class.toast-new]="isNew"
[class.toast-success]="toast.level === ToastLevel.Success"
[class.toast-error]="toast.level === ToastLevel.Error"
(mouseenter)="setHovered(true)"
(mouseleave)="setHovered(false)"
(focusin)="setFocused(true)"
(focusout)="setFocused(false)"
(shown)="onShown(toast)"
(hidden)="hidden.emit(toast)">
@if (autohide) {
<ngb-progressbar class="position-absolute h-100 w-100 top-90 start-0 bottom-0 end-0 pe-none" type="dark" [max]="toast.delay" [value]="toast.delayRemaining"></ngb-progressbar>
<span class="visually-hidden">{{ toast.delayRemaining / 1000 | number: '1.0-0' }} seconds</span>
<ngb-progressbar class="position-absolute w-100 start-0 bottom-0 pe-none" [max]="toast.delay" [value]="delayRemaining()"></ngb-progressbar>
<span class="visually-hidden">{{ delayRemaining() / 1000 | number: '1.0-0' }} seconds</span>
}
<div class="d-flex align-items-top">
@if (!toast.error) {
<i-bs width="0.9em" height="0.9em" name="info-circle"></i-bs>
@switch (toast.level) {
@case (ToastLevel.Success) {
<i-bs width="0.9em" height="0.9em" name="check-circle" class="toast-icon"></i-bs>
}
@case (ToastLevel.Error) {
<i-bs width="0.9em" height="0.9em" name="exclamation-triangle" class="toast-icon"></i-bs>
}
@default {
<i-bs width="0.9em" height="0.9em" name="info-circle" class="toast-icon"></i-bs>
}
}
@if (toast.error) {
<i-bs width="0.9em" height="0.9em" name="exclamation-triangle"></i-bs>
}
<div>
<div class="toast-content">
<p class="ms-2 mb-0 text-break">{{toast.content}}</p>
@if (toast.error) {
<details class="ms-2">
@@ -48,9 +59,12 @@
</details>
}
@if (toast.action) {
<p class="mb-0 mt-2"><button class="btn btn-sm btn-outline-secondary" (click)="closed.emit(toast); toast.action()">{{toast.actionName}}</button></p>
<button class="btn btn-sm toast-action ms-2 mt-2" (click)="actioned.emit(toast); toast.action()">{{toast.actionName}}</button>
}
</div>
@if (!autohide && toast.timestamp) {
<span class="toast-time small text-body-secondary text-nowrap ms-auto ps-2">{{relativeTimestamp}}</span>
}
<button type="button" class="btn-close ms-auto flex-shrink-0" data-bs-dismiss="toast" aria-label="Close" (click)="closed.emit(toast);"></button>
</div>
</ngb-toast>
@@ -2,19 +2,105 @@
position: relative;
}
::ng-deep .toast.error {
border-color: hsla(350, 79%, 40%, 0.4); // bg-danger
.toast {
--pngx-toast-accent: var(--pngx-toast-info);
background-color: var(--pngx-toast-bg);
color: var(--bs-body-color);
overflow: hidden;
&.toast-success {
--pngx-toast-accent: var(--pngx-toast-success);
background-color: var(--pngx-toast-success-bg);
border-color: color-mix(in srgb, var(--pngx-toast-success) 30%, transparent);
}
&.toast-error {
--pngx-toast-accent: var(--pngx-toast-error);
background-color: var(--pngx-toast-error-bg);
border-color: color-mix(in srgb, var(--pngx-toast-error) 35%, transparent);
}
}
::ng-deep .toast.error .toast-body {
background-color: hsla(350, 79%, 40%, 0.8); // bg-danger
border-top-left-radius: inherit;
border-top-right-radius: inherit;
border-bottom-left-radius: inherit;
border-bottom-right-radius: inherit;
.toast-icon {
color: var(--pngx-toast-accent);
}
.toast-content {
flex: 1 1 auto;
min-width: 0;
}
.toast .btn-close {
font-size: .6rem;
margin-top: .3rem;
margin-left: .75rem !important;
opacity: .45;
}
.toast:hover .btn-close {
opacity: .85;
}
// compact rows in the notifications dropdown
.toast.toast-list-item {
width: 100%;
max-width: none;
background-color: var(--pngx-toast-row-bg);
border: 0;
box-shadow: none;
&:hover {
background-color: var(--pngx-toast-row-hover-bg);
}
&.toast-error {
background-color: var(--pngx-toast-error-bg);
}
::ng-deep .toast-body {
padding: .625rem .75rem;
}
.toast-time {
margin-top: .1rem;
}
// marks notifications that arrived since the dropdown was last opened
&.toast-new .toast-time::before {
content: '';
display: inline-block;
width: .45rem;
height: .45rem;
margin-right: .4rem;
vertical-align: .05rem;
border-radius: 50%;
background-color: var(--pngx-toast-info);
}
}
.toast-action {
--bs-btn-padding-y: .1rem;
--bs-btn-padding-x: .6rem;
--bs-btn-font-size: .8rem;
--bs-btn-font-weight: 600;
--bs-btn-color: var(--pngx-toast-accent);
--bs-btn-bg: color-mix(in srgb, var(--pngx-toast-accent) 14%, transparent);
--bs-btn-border-color: transparent;
--bs-btn-hover-color: var(--pngx-toast-accent);
--bs-btn-hover-bg: color-mix(in srgb, var(--pngx-toast-accent) 24%, transparent);
--bs-btn-hover-border-color: transparent;
--bs-btn-active-color: var(--pngx-toast-accent);
--bs-btn-active-bg: color-mix(in srgb, var(--pngx-toast-accent) 30%, transparent);
--bs-btn-active-border-color: transparent;
}
// countdown thin line
.progress {
background-color: var(--pngx-primary);
opacity: .07;
height: 2px;
background-color: transparent;
::ng-deep .progress-bar {
background-color: var(--bs-secondary-color);
opacity: .5;
}
}
@@ -1,7 +1,11 @@
import { DatePipe } from '@angular/common'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { Clipboard } from '@angular/cdk/clipboard'
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { ToastLevel } from 'src/app/services/toast.service'
import { ToastComponent } from './toast.component'
const toast1 = {
@@ -30,6 +34,11 @@ describe('ToastComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ToastComponent, NgxBootstrapIconsModule.pick(allIcons)],
providers: [
DatePipe,
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
}).compileComponents()
fixture = TestBed.createComponent(ToastComponent)
@@ -46,11 +55,117 @@ describe('ToastComponent', () => {
component.toast = toast2
fixture.detectChanges()
component.onShown(toast2)
jest.advanceTimersByTime(5000)
expect(component.toast.delayRemaining).toEqual(0)
jest.advanceTimersByTime(5000 + 150) // ticks every 150ms
expect(component.delayRemaining()).toEqual(0)
jest.useRealTimers()
})
it('should start the progress bar full before the countdown begins', () => {
component.toast = { content: 'Info', delay: 3000 }
fixture.detectChanges()
expect(component.delayRemaining()).toEqual(3000)
})
it('should hide the toast when the countdown ends', () => {
jest.useFakeTimers()
const toast = { content: 'Info', delay: 3000 }
component.toast = toast
fixture.detectChanges()
const hideSpy = jest.spyOn(component.ngbToast, 'hide')
component.onShown(toast)
jest.advanceTimersByTime(2850)
expect(hideSpy).not.toHaveBeenCalled()
jest.advanceTimersByTime(300)
expect(hideSpy).toHaveBeenCalled()
jest.useRealTimers()
})
it('should pause the countdown while hovered', () => {
jest.useFakeTimers()
const toast = { content: 'Info', delay: 3000 }
component.toast = toast
fixture.detectChanges()
const hideSpy = jest.spyOn(component.ngbToast, 'hide')
const el: HTMLElement = fixture.nativeElement.querySelector('ngb-toast')
component.onShown(toast)
jest.advanceTimersByTime(1500)
el.dispatchEvent(new MouseEvent('mouseenter'))
const remaining = component.delayRemaining()
jest.advanceTimersByTime(10000)
expect(component.delayRemaining()).toEqual(remaining)
expect(hideSpy).not.toHaveBeenCalled()
el.dispatchEvent(new MouseEvent('mouseleave'))
jest.advanceTimersByTime(remaining + 150)
expect(hideSpy).toHaveBeenCalled()
jest.useRealTimers()
})
it('should pause the countdown while focus is inside', () => {
jest.useFakeTimers()
const toast = { content: 'Info', delay: 3000 }
component.toast = toast
fixture.detectChanges()
const el: HTMLElement = fixture.nativeElement.querySelector('ngb-toast')
component.onShown(toast)
el.dispatchEvent(new FocusEvent('focusin'))
// mouse leaving doesn't resume while focus is still inside
el.dispatchEvent(new MouseEvent('mouseenter'))
el.dispatchEvent(new MouseEvent('mouseleave'))
jest.advanceTimersByTime(5000)
expect(component.delayRemaining()).toEqual(3000)
el.dispatchEvent(new FocusEvent('focusout'))
jest.advanceTimersByTime(3000)
expect(component.delayRemaining()).toEqual(0)
jest.useRealTimers()
})
it('should not count down without autohide', () => {
jest.useFakeTimers()
const toast = { content: 'Info', delay: 3000 }
component.toast = toast
component.autohide = false
fixture.detectChanges()
component.onShown(toast)
jest.advanceTimersByTime(5000)
expect(component.delayRemaining()).toBeNull()
jest.useRealTimers()
})
it('should show an icon and colors for each level', () => {
component.toast = {
content: 'Saved',
delay: 5000,
level: ToastLevel.Success,
}
fixture.detectChanges()
const el: HTMLElement = fixture.nativeElement
expect(el.querySelector('i-bs[name="check-circle"]')).not.toBeNull()
expect(el.querySelector('ngb-toast').classList).not.toContain('toast-error')
fixture = TestBed.createComponent(ToastComponent)
fixture.componentInstance.toast = {
content: 'Failed',
delay: 5000,
level: ToastLevel.Error,
}
fixture.detectChanges()
const errorEl: HTMLElement = fixture.nativeElement
expect(
errorEl.querySelector('i-bs[name="exclamation-triangle"]')
).not.toBeNull()
expect(errorEl.querySelector('ngb-toast').classList).toContain(
'toast-error'
)
fixture = TestBed.createComponent(ToastComponent)
fixture.componentInstance.toast = { content: 'Started', delay: 5000 }
fixture.detectChanges()
expect(
fixture.nativeElement.querySelector('i-bs[name="info-circle"]')
).not.toBeNull()
})
it('should show an error if given with toast', () => {
component.toast = toast1
fixture.detectChanges()
@@ -88,4 +203,70 @@ describe('ToastComponent', () => {
component.getErrorText({ error: new Array(205).join('a') })
).toContain('...')
})
it('should emit actioned, not closed, when the action is clicked', () => {
const action = jest.fn()
component.toast = {
content: 'Moved',
delay: 5000,
action,
actionName: 'Undo',
}
fixture.detectChanges()
const closedSpy = jest.spyOn(component.closed, 'emit')
const actionedSpy = jest.spyOn(component.actioned, 'emit')
const button: HTMLButtonElement = [
...fixture.nativeElement.querySelectorAll('button'),
].find((b) => b.textContent.includes('Undo'))
button.click()
expect(actionedSpy).toHaveBeenCalled()
expect(closedSpy).not.toHaveBeenCalled()
expect(action).toHaveBeenCalled()
})
it('should show a relative timestamp in the notifications list', () => {
component.toast = { content: 'Saved', delay: 5000, timestamp: new Date() }
component.autohide = false
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('Just now')
})
it('should update the relative timestamp as time passes', () => {
jest.useFakeTimers()
jest.setSystemTime(new Date('2026-10-19T12:00:00Z'))
component.toast = { content: 'Saved', delay: 5000, timestamp: new Date() }
component.autohide = false
fixture.componentRef.setInput('now', new Date())
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('Just now')
jest.setSystemTime(new Date('2026-10-19T12:03:00Z'))
fixture.componentRef.setInput('now', new Date())
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('3 minutes ago')
jest.useRealTimers()
})
it('should show a compact action link in the notifications list', () => {
const action = jest.fn()
component.toast = {
content: 'Moved',
delay: 5000,
action,
actionName: 'Undo',
timestamp: new Date(),
}
component.autohide = false
fixture.detectChanges()
const link: HTMLButtonElement =
fixture.nativeElement.querySelector('.toast-action')
expect(link.textContent).toContain('Undo')
expect(
fixture.nativeElement.querySelector('.btn-outline-secondary')
).toBeNull()
const actionedSpy = jest.spyOn(component.actioned, 'emit')
link.click()
expect(actionedSpy).toHaveBeenCalled()
expect(action).toHaveBeenCalled()
})
})
@@ -1,20 +1,26 @@
import { Clipboard } from '@angular/cdk/clipboard'
import { DecimalPipe } from '@angular/common'
import { DatePipe, DecimalPipe } from '@angular/common'
import {
Component,
DestroyRef,
EventEmitter,
Input,
OnInit,
Output,
ViewChild,
inject,
signal,
} from '@angular/core'
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import {
NgbProgressbarModule,
NgbToast,
NgbToastModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { interval, take } from 'rxjs'
import { Toast } from 'src/app/services/toast.service'
import { Subscription, interval, takeWhile } from 'rxjs'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { Toast, ToastLevel } from 'src/app/services/toast.service'
@Component({
selector: 'pngx-toast',
@@ -26,32 +32,79 @@ import { Toast } from 'src/app/services/toast.service'
],
templateUrl: './toast.component.html',
styleUrl: './toast.component.scss',
providers: [DatePipe, CustomDatePipe],
})
export class ToastComponent {
export class ToastComponent implements OnInit {
private clipboard = inject(Clipboard)
private customDatePipe = inject(CustomDatePipe)
private destroyRef = inject(DestroyRef)
readonly copied = signal(false)
readonly ToastLevel = ToastLevel
readonly delayRemaining = signal<number>(null)
@ViewChild(NgbToast) ngbToast: NgbToast
private hovered = false
private focused = false
private countdown: Subscription
@Input() toast: Toast
@Input() autohide: boolean = true
/** Each new value re-renders the relative timestamp */
@Input() now: Date
/** Marks a notification that arrived since the dropdown was last opened */
@Input() isNew: boolean = false
@Output() hidden: EventEmitter<Toast> = new EventEmitter<Toast>()
@Output() closed: EventEmitter<Toast> = new EventEmitter<Toast>()
/** The toast's action button was clicked */
@Output() actioned: EventEmitter<Toast> = new EventEmitter<Toast>()
ngOnInit(): void {
if (this.autohide) this.delayRemaining.set(this.toast.delay)
}
get relativeTimestamp(): string {
return this.customDatePipe.transform(this.toast.timestamp, 'relative')
}
get paused(): boolean {
return this.hovered || this.focused
}
setHovered(hovered: boolean) {
this.hovered = hovered
}
setFocused(focused: boolean) {
this.focused = focused
}
onShown(toast: Toast) {
if (!this.autohide) return
const refreshInterval = 150
const delay = toast.delay - 500 // for fade animation
this.delayRemaining.set(toast.delay)
interval(refreshInterval)
.pipe(take(Math.round(delay / refreshInterval)))
.subscribe((count) => {
toast.delayRemaining = Math.max(
0,
delay - refreshInterval * (count + 1)
this.countdown?.unsubscribe()
this.countdown = interval(refreshInterval)
.pipe(
takeWhile(() => this.delayRemaining() > 0),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => {
if (this.paused) return
this.delayRemaining.update((remaining) =>
Math.max(0, remaining - refreshInterval)
)
if (this.delayRemaining() === 0) {
this.ngbToast?.hide()
}
})
}
@@ -1,3 +1,3 @@
@for (toast of toasts(); track toast.id) {
<pngx-toast [toast]="toast" [autohide]="true" (closed)="closeToast()"></pngx-toast>
<pngx-toast [toast]="toast" [autohide]="true" (closed)="removePopup(toast)" (actioned)="closeToast(toast)" (hidden)="removePopup(toast)"></pngx-toast>
}
@@ -1,10 +1,11 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { By } from '@angular/platform-browser'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { Subject } from 'rxjs'
import { Toast, ToastService } from 'src/app/services/toast.service'
import { ToastsComponent } from './toasts.component'
import { MAX_VISIBLE_TOASTS, ToastsComponent } from './toasts.component'
const toast = {
content: 'Error 2 content',
@@ -50,7 +51,7 @@ describe('ToastsComponent', () => {
it('should close toast', () => {
toastSubject.next(toast)
const closeToastSpy = jest.spyOn(toastService, 'closeToast')
component.closeToast()
component.closeToast(toast)
expect(component.toasts()).toEqual([])
expect(closeToastSpy).toHaveBeenCalledWith(toast)
})
@@ -59,4 +60,59 @@ describe('ToastsComponent', () => {
toastSubject.next(toast)
expect(component.toasts()).toEqual([toast])
})
it('should stack popups newest first, up to the maximum', () => {
const toasts: Toast[] = Array.from(
{ length: MAX_VISIBLE_TOASTS + 1 },
(_, i) => ({ id: `${i}`, content: `Toast ${i}`, delay: 5000 })
)
toasts.forEach((t) => toastSubject.next(t))
expect(component.toasts().map((t) => t.id)).toEqual(['3', '2', '1'])
// showing a visible toast again moves it to the top without duplicating
toastSubject.next(toasts[1])
expect(component.toasts().map((t) => t.id)).toEqual(['1', '3', '2'])
})
it('should remove a popup when it hides without closing it', () => {
const closeToastSpy = jest.spyOn(toastService, 'closeToast')
const first = { id: 'a', content: 'A', delay: 5000 }
const second = { id: 'b', content: 'B', delay: 5000 }
toastSubject.next(first)
toastSubject.next(second)
component.removePopup(first)
expect(component.toasts()).toEqual([second])
expect(closeToastSpy).not.toHaveBeenCalled()
})
it('should clear popups when toastService emits null', () => {
toastSubject.next(toast)
toastSubject.next(null)
expect(component.toasts()).toEqual([])
})
it('should render one toast component per popup', () => {
toastSubject.next({ id: 'a', content: 'A', delay: 5000 })
toastSubject.next({ id: 'b', content: 'B', delay: 5000 })
fixture.detectChanges()
expect(fixture.nativeElement.querySelectorAll('pngx-toast')).toHaveLength(2)
})
it('should only hide the popup on close, but clear it on action', () => {
const closeToastSpy = jest.spyOn(toastService, 'closeToast')
const first = { id: 'a', content: 'A', delay: 5000 }
const second = { id: 'b', content: 'B', delay: 5000 }
toastSubject.next(first)
toastSubject.next(second)
fixture.detectChanges()
const popups = fixture.debugElement.queryAll(By.css('pngx-toast'))
popups[1].triggerEventHandler('closed', first)
expect(closeToastSpy).not.toHaveBeenCalled()
expect(component.toasts()).toEqual([second])
popups[0].triggerEventHandler('actioned', second)
expect(closeToastSpy).toHaveBeenCalledWith(second)
expect(component.toasts()).toEqual([])
})
})
@@ -1,14 +1,18 @@
import { Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { Component, inject, signal } from '@angular/core'
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import {
NgbAccordionModule,
NgbProgressbarModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { map, merge, Subject } from 'rxjs'
import { Toast, ToastService } from 'src/app/services/toast.service'
import { ToastComponent } from '../toast/toast.component'
/**
* Maximum number of popup toasts shown at once
*/
export const MAX_VISIBLE_TOASTS = 3
@Component({
selector: 'pngx-toasts',
templateUrl: './toasts.component.html',
@@ -23,18 +27,31 @@ import { ToastComponent } from '../toast/toast.component'
export class ToastsComponent {
toastService = inject(ToastService)
private readonly closedToast = new Subject<void>()
readonly toasts = signal<Toast[]>([])
readonly toasts = toSignal(
merge(
this.toastService.showToast.pipe(map((toast) => (toast ? [toast] : []))),
this.closedToast.pipe(map(() => [] as Toast[]))
),
{ initialValue: [] as Toast[] }
)
constructor() {
this.toastService.showToast
.pipe(takeUntilDestroyed())
.subscribe((toast) => {
if (!toast) {
this.toasts.set([])
return
}
this.toasts.update((toasts) =>
[toast, ...toasts.filter((t) => t.id !== toast.id)].slice(
0,
MAX_VISIBLE_TOASTS
)
)
})
}
closeToast() {
this.toastService.closeToast(this.toasts()[0])
this.closedToast.next()
closeToast(toast: Toast) {
this.toastService.closeToast(toast)
this.removePopup(toast)
}
removePopup(toast: Toast) {
this.toasts.update((toasts) => toasts.filter((t) => t.id !== toast.id))
}
}
@@ -0,0 +1,52 @@
@if (canAddDocuments && getStatus().length > 0) {
<div class="fixed-bottom p-2 p-md-4 d-flex justify-content-end pe-none consumer-status-list" [ngClass]="slimSidebarEnabled ? 'col-slim' : 'offset-sidebar-expanded'">
<div class="col col-lg-4 col-xl-3 ps-0 pe-0 ps-lg-3 pe-lg-0 pe-auto overflow-y-auto">
<div class="card shadow-sm consumer-status-card">
<div class="card-body">
<div class="d-flex flex-wrap align-items-center column-gap-2">
<p class="m-0 small text-muted">{{getStatusSummary()}}</p>
<div class="d-flex align-items-center gap-2 ms-auto">
@if (getStatusCompleted().length > 0) {
<a class="btn-link small text-nowrap" (click)="dismissCompleted()" [routerLink]="[]">
<span class="me-1" i18n="This button dismisses all status messages about processed documents in the tray (failed and successful)">Dismiss completed</span>
<i-bs name="check2-all"></i-bs>
</a>
}
<button type="button" class="btn btn-sm btn-link p-0 text-muted" (click)="toggleCollapsed()"
[attr.aria-expanded]="!collapsed()" aria-label="Toggle document processing status" i18n-aria-label>
<i-bs [name]="collapsed() ? 'chevron-up' : 'chevron-down'"></i-bs>
</button>
</div>
</div>
@if (!collapsed()) {
@for (status of getStatus(); track status) {
<ng-container [ngTemplateOutlet]="consumerAlert" [ngTemplateOutletContext]="{ $implicit: status }"></ng-container>
}
}
</div>
</div>
</div>
</div>
}
<ng-template #consumerAlert let-status>
<ngb-alert type="secondary" class="mt-2 mb-0" [dismissible]="isFinished(status)" (closed)="dismiss(status)">
<h6 class="alert-heading">{{status.filename}}</h6>
@if (!isFinished(status) || (isFinished(status) && !status.documentId)) {
<p class="mb-0 pb-1">{{status.message}}</p>
}
<ngb-progressbar [value]="status.getProgress()" [max]="1" [type]="getStatusColor(status)"></ngb-progressbar>
<div *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
@if (isFinished(status)) {
<div>
@if (status.documentId) {
<a class="btn btn-sm btn-outline-primary btn-open" [routerLink]="['/documents', status.documentId]" (click)="dismiss(status)">
<small i18n>Open document</small>
<i-bs name="arrow-right-short"></i-bs>
</a>
}
</div>
}
</div>
</ngb-alert>
</ng-template>
@@ -0,0 +1,36 @@
:host ::ng-deep i-bs svg {
margin-top: -3px;
}
.alert-heading {
font-size: 80%;
font-weight: bold;
}
.btn-open {
line-height: 1;
i-bs {
margin-top: -1px;
}
}
.consumer-status-card {
background-color: rgba(var(--bs-body-bg-rgb), .95) !important;
}
.consumer-status-list {
max-height: 60vh;
z-index: 1030;
}
.progress {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
height: auto;
mix-blend-mode: soft-light;
pointer-events: none;
}
@@ -0,0 +1,186 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { By } from '@angular/platform-browser'
import { RouterTestingModule } from '@angular/router/testing'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { routes } from 'src/app/app-routing.module'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { PermissionsService } from 'src/app/services/permissions.service'
import {
FileStatus,
FileStatusPhase,
WebsocketStatusService,
} from 'src/app/services/websocket-status.service'
import { UploadStatusTrayComponent } from './upload-status-tray.component'
const FAILED_STATUSES = [new FileStatus()]
const WORKING_STATUSES = [new FileStatus(), new FileStatus()]
const STARTED_STATUSES = [new FileStatus(), new FileStatus(), new FileStatus()]
const SUCCESS_STATUSES = [
new FileStatus(),
new FileStatus(),
new FileStatus(),
new FileStatus(),
]
const DEFAULT_STATUSES = [
new FileStatus(),
new FileStatus(),
new FileStatus(),
new FileStatus(),
new FileStatus(),
new FileStatus(),
]
describe('UploadStatusTrayComponent', () => {
let component: UploadStatusTrayComponent
let fixture: ComponentFixture<UploadStatusTrayComponent>
let websocketStatusService: WebsocketStatusService
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [
RouterTestingModule.withRoutes(routes),
NgxBootstrapIconsModule.pick(allIcons),
UploadStatusTrayComponent,
],
providers: [
PermissionsGuard,
{
provide: PermissionsService,
useValue: {
currentUserCan: () => true,
},
},
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
}).compileComponents()
websocketStatusService = TestBed.inject(WebsocketStatusService)
fixture = TestBed.createComponent(UploadStatusTrayComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should not render without statuses', () => {
expect(
fixture.nativeElement.querySelector('.consumer-status-card')
).toBeNull()
})
it('should render statuses and support collapsing', () => {
const status = new FileStatus()
status.filename = 'invoice.pdf'
status.phase = FileStatusPhase.WORKING
jest
.spyOn(websocketStatusService, 'getConsumerStatus')
.mockReturnValue([status])
// a mocked return value doesn't mark the zoneless view dirty, so re-create it
fixture = TestBed.createComponent(UploadStatusTrayComponent)
component = fixture.componentInstance
fixture.detectChanges()
const el: HTMLElement = fixture.nativeElement
expect(el.querySelector('.consumer-status-card')).not.toBeNull()
expect(el.textContent).toContain('invoice.pdf')
component.toggleCollapsed()
fixture.detectChanges()
expect(el.querySelector('.consumer-status-card')).not.toBeNull()
expect(el.textContent).not.toContain('invoice.pdf')
component.toggleCollapsed()
fixture.detectChanges()
expect(el.textContent).toContain('invoice.pdf')
})
it('should expand again once all statuses are dismissed', () => {
const status = websocketStatusService.newFileUpload('invoice.pdf')
fixture.detectChanges()
component.toggleCollapsed()
expect(component.collapsed()).toBe(true)
websocketStatusService.dismiss(status)
fixture.detectChanges()
expect(component.collapsed()).toBe(false)
})
it('should generate stats summary', () => {
mockConsumerStatuses(websocketStatusService)
expect(component.getStatusSummary()).toEqual(
'Processing: 6, Failed: 1, Added: 4'
)
})
it('should report an upload progress summary', () => {
mockConsumerStatuses(websocketStatusService)
expect(component.getTotalUploadProgress()).toEqual(0.75)
})
it('should change color by status phase', () => {
const processingStatus = new FileStatus()
processingStatus.phase = FileStatusPhase.WORKING
expect(component.getStatusColor(processingStatus)).toEqual('primary')
processingStatus.phase = FileStatusPhase.UPLOADING
expect(component.getStatusColor(processingStatus)).toEqual('primary')
const failedStatus = new FileStatus()
failedStatus.phase = FileStatusPhase.FAILED
expect(component.getStatusColor(failedStatus)).toEqual('danger')
const successStatus = new FileStatus()
successStatus.phase = FileStatusPhase.SUCCESS
expect(component.getStatusColor(successStatus)).toEqual('success')
})
it('should allow dismissing an alert', () => {
const dismissSpy = jest.spyOn(websocketStatusService, 'dismiss')
component.dismiss(new FileStatus())
expect(dismissSpy).toHaveBeenCalled()
})
it('should allow dismissing completed alerts', () => {
jest.useFakeTimers()
mockConsumerStatuses(websocketStatusService)
fixture.detectChanges()
jest
.spyOn(component, 'getStatusCompleted')
.mockImplementation(() => SUCCESS_STATUSES)
const dismissSpy = jest.spyOn(websocketStatusService, 'dismiss')
component.dismissCompleted()
jest.advanceTimersByTime(1000)
fixture.detectChanges()
expect(dismissSpy).toHaveBeenCalledTimes(4)
jest.useRealTimers()
})
})
function mockConsumerStatuses(consumerStatusService) {
const partialUpload1 = new FileStatus()
partialUpload1.currentPhaseProgress = 50
partialUpload1.currentPhaseMaxProgress = 50
const partialUpload2 = new FileStatus()
partialUpload2.currentPhaseProgress = 25
partialUpload2.currentPhaseMaxProgress = 50
jest
.spyOn(consumerStatusService, 'getConsumerStatus')
.mockImplementation((phase) => {
switch (phase) {
case FileStatusPhase.FAILED:
return FAILED_STATUSES
case FileStatusPhase.WORKING:
return WORKING_STATUSES
case FileStatusPhase.STARTED:
return STARTED_STATUSES
case FileStatusPhase.SUCCESS:
return SUCCESS_STATUSES
case FileStatusPhase.UPLOADING:
return [partialUpload1, partialUpload2]
default:
return DEFAULT_STATUSES
}
})
jest
.spyOn(consumerStatusService, 'getConsumerStatusNotCompleted')
.mockImplementation(() => {
return DEFAULT_STATUSES
})
}
@@ -0,0 +1,154 @@
import { NgClass, NgTemplateOutlet } from '@angular/common'
import { Component, effect, inject, signal } from '@angular/core'
import { RouterModule } from '@angular/router'
import {
NgbAlertModule,
NgbProgressbarModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import {
PermissionAction,
PermissionsService,
PermissionType,
} from 'src/app/services/permissions.service'
import { SettingsService } from 'src/app/services/settings.service'
import {
FileStatus,
FileStatusPhase,
WebsocketStatusService,
} from 'src/app/services/websocket-status.service'
/** Floating list of document consumption statuses, shown on every page */
@Component({
selector: 'pngx-upload-status-tray',
templateUrl: './upload-status-tray.component.html',
styleUrls: ['./upload-status-tray.component.scss'],
imports: [
IfPermissionsDirective,
NgClass,
NgTemplateOutlet,
RouterModule,
NgbAlertModule,
NgbProgressbarModule,
NgxBootstrapIconsModule,
],
})
export class UploadStatusTrayComponent extends ComponentWithPermissions {
private websocketStatusService = inject(WebsocketStatusService)
private settingsService = inject(SettingsService)
private permissionsService = inject(PermissionsService)
readonly collapsed = signal(false)
constructor() {
super()
// start expanded again for the next uploads once everything is dismissed
effect(() => {
if (this.getStatus().length === 0) this.collapsed.set(false)
})
}
get canAddDocuments(): boolean {
return this.permissionsService.currentUserCan(
PermissionAction.Add,
PermissionType.Document
)
}
getStatus() {
return this.websocketStatusService.getConsumerStatus()
}
getStatusSummary() {
let strings = []
let countUploadingAndProcessing =
this.websocketStatusService.getConsumerStatusNotCompleted().length
let countFailed = this.getStatusFailed().length
let countSuccess = this.getStatusSuccess().length
if (countUploadingAndProcessing > 0) {
strings.push($localize`Processing: ${countUploadingAndProcessing}`)
}
if (countFailed > 0) {
strings.push($localize`Failed: ${countFailed}`)
}
if (countSuccess > 0) {
strings.push($localize`Added: ${countSuccess}`)
}
return strings.join(
$localize`:this string is used to separate processing, failed and added on the file upload widget:, `
)
}
getStatusUploading() {
return this.websocketStatusService.getConsumerStatus(
FileStatusPhase.UPLOADING
)
}
getStatusFailed() {
return this.websocketStatusService.getConsumerStatus(FileStatusPhase.FAILED)
}
getStatusSuccess() {
return this.websocketStatusService.getConsumerStatus(
FileStatusPhase.SUCCESS
)
}
getStatusCompleted() {
return this.websocketStatusService.getConsumerStatusCompleted()
}
getTotalUploadProgress() {
let current = 0
let max = 0
this.getStatusUploading().forEach((status) => {
current += status.currentPhaseProgress
max += status.currentPhaseMaxProgress
})
return current / Math.max(max, 1)
}
isFinished(status: FileStatus) {
return (
status.phase == FileStatusPhase.FAILED ||
status.phase == FileStatusPhase.SUCCESS
)
}
getStatusColor(status: FileStatus) {
switch (status.phase) {
case FileStatusPhase.UPLOADING:
case FileStatusPhase.STARTED:
case FileStatusPhase.WORKING:
return 'primary'
case FileStatusPhase.FAILED:
return 'danger'
case FileStatusPhase.SUCCESS:
return 'success'
}
}
toggleCollapsed() {
this.collapsed.update((collapsed) => !collapsed)
}
dismiss(status: FileStatus) {
this.websocketStatusService.dismiss(status)
}
dismissCompleted() {
this.getStatusCompleted().forEach((status) =>
this.websocketStatusService.dismiss(status)
)
}
get slimSidebarEnabled(): boolean {
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
}
}
@@ -173,7 +173,7 @@ describe('DashboardComponent', () => {
it('should update saved view sorting on drag + drop, show info', () => {
const settingsSpy = jest.spyOn(settingsService, 'updateDashboardViewsSort')
const toastSpy = jest.spyOn(toastService, 'showInfo')
const toastSpy = jest.spyOn(toastService, 'showSuccess')
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
component.onDrop({ previousIndex: 0, currentIndex: 1 } as CdkDragDrop<
SavedView[]
@@ -87,7 +87,7 @@ export class DashboardComponent extends ComponentWithPermissions {
.updateDashboardViewsSort(this.dashboardViews())
.subscribe({
next: () => {
this.toastService.showInfo($localize`Dashboard updated`)
this.toastService.showSuccess($localize`Dashboard updated`)
},
error: (e) => {
this.toastService.showError($localize`Error updating dashboard`, e)
@@ -8,59 +8,5 @@
</button>
<input type="file" class="visually-hidden" (change)="onFileSelected($event)" multiple #fileUpload>
</form>
@if (getStatus().length > 0) {
<div class="fixed-bottom p-2 p-md-4 d-flex justify-content-end pe-none consumer-status-list" [ngClass]="slimSidebarEnabled ? 'col-slim' : 'offset-sidebar-expanded'">
<div class="col col-lg-4 col-xl-3 ps-0 pe-0 ps-lg-3 pe-lg-0 pe-auto overflow-y-scroll">
<div class="card shadow-sm consumer-status-card">
<div class="card-body">
<div class="row">
<div class="col d-flex justify-content-between align-items-center">
@if (getStatus().length > 0) {
<p class="m-0 small text-muted">{{getStatusSummary()}}</p>
}
@if (getStatusCompleted().length > 0) {
<a class="btn-link" (click)="dismissCompleted()" [routerLink]="[]" >
<span class="me-1" i18n="This button dismisses all status messages about processed documents on the dashboard (failed and successful)">Dismiss completed</span>
<i-bs name="check2-all"></i-bs>
</a>
}
</div>
</div>
<div class="row">
<div class="col">
@for (status of getStatus(); track status) {
<div>
<ng-container [ngTemplateOutlet]="consumerAlert" [ngTemplateOutletContext]="{ $implicit: status }"></ng-container>
</div>
}
</div>
</div>
</div>
</div>
</div>
</div>
}
</div>
</pngx-widget-frame>
<ng-template #consumerAlert let-status>
<ngb-alert type="secondary" class="mt-2 mb-0" [dismissible]="isFinished(status)" (closed)="dismiss(status)">
<h6 class="alert-heading">{{status.filename}}</h6>
@if (!isFinished(status) || (isFinished(status) && !status.documentId)) {
<p class="mb-0 pb-1">{{status.message}}</p>
}
<ngb-progressbar [value]="status.getProgress()" [max]="1" [type]="getStatusColor(status)"></ngb-progressbar>
<div *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
@if (isFinished(status)) {
<div>
@if (status.documentId) {
<a class="btn btn-sm btn-outline-primary btn-open" [routerLink]="['/documents', status.documentId]" (click)="dismiss(status)">
<small i18n>Open document</small>
<i-bs name="arrow-right-short"></i-bs>
</a>
}
</div>
}
</div>
</ngb-alert>
</ng-template>
@@ -20,48 +20,6 @@
font-size: 0.75rem;
}
.alert-heading {
font-size: 80%;
font-weight: bold;
}
.alerts-hidden {
.btn {
line-height: 1;
}
}
.btn-open {
line-height: 1;
i-bs {
margin-top: -1px;
}
}
.progress {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
height: auto;
mix-blend-mode: soft-light;
pointer-events: none;
}
::ng-deep .ngx-file-drop__drop-zone--over {
background-color: var(--pngx-primary-faded) !important;
}
.consumer-status-card {
background-color: rgba(var(--bs-body-bg-rgb), .95) !important;
}
.consumer-status-list {
max-height: calc(100vh - 312px); // e.g. below the upload button, mobile
@media screen and (min-width: 768px) {
max-height: calc(100vh - 208px); // e.g. below the upload button
}
}
@@ -9,35 +9,11 @@ import { routes } from 'src/app/app-routing.module'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { PermissionsService } from 'src/app/services/permissions.service'
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
import {
FileStatus,
FileStatusPhase,
WebsocketStatusService,
} from 'src/app/services/websocket-status.service'
import { UploadFileWidgetComponent } from './upload-file-widget.component'
const FAILED_STATUSES = [new FileStatus()]
const WORKING_STATUSES = [new FileStatus(), new FileStatus()]
const STARTED_STATUSES = [new FileStatus(), new FileStatus(), new FileStatus()]
const SUCCESS_STATUSES = [
new FileStatus(),
new FileStatus(),
new FileStatus(),
new FileStatus(),
]
const DEFAULT_STATUSES = [
new FileStatus(),
new FileStatus(),
new FileStatus(),
new FileStatus(),
new FileStatus(),
new FileStatus(),
]
describe('UploadFileWidgetComponent', () => {
let component: UploadFileWidgetComponent
let fixture: ComponentFixture<UploadFileWidgetComponent>
let websocketStatusService: WebsocketStatusService
let uploadDocumentsService: UploadDocumentsService
beforeEach(async () => {
@@ -61,7 +37,6 @@ describe('UploadFileWidgetComponent', () => {
],
}).compileComponents()
websocketStatusService = TestBed.inject(WebsocketStatusService)
uploadDocumentsService = TestBed.inject(UploadDocumentsService)
fixture = TestBed.createComponent(UploadFileWidgetComponent)
component = fixture.componentInstance
@@ -85,93 +60,10 @@ describe('UploadFileWidgetComponent', () => {
'file.pdf'
)
const fileInput = fixture.debugElement.query(By.css('input'))
jest.spyOn(fileInput.nativeElement, 'files', 'get').mockReturnValue({
item: () => file,
length: 1,
[Symbol.iterator]: () => ({
next: () => ({ done: false, value: file }),
}),
} as any)
fileInput.nativeElement.dispatchEvent(new Event('change'))
expect(uploadSpy).toHaveBeenCalled()
})
it('should generate stats summary', () => {
mockConsumerStatuses(websocketStatusService)
expect(component.getStatusSummary()).toEqual(
'Processing: 6, Failed: 1, Added: 4'
)
})
it('should report an upload progress summary', () => {
mockConsumerStatuses(websocketStatusService)
expect(component.getTotalUploadProgress()).toEqual(0.75)
})
it('should change color by status phase', () => {
const processingStatus = new FileStatus()
processingStatus.phase = FileStatusPhase.WORKING
expect(component.getStatusColor(processingStatus)).toEqual('primary')
processingStatus.phase = FileStatusPhase.UPLOADING
expect(component.getStatusColor(processingStatus)).toEqual('primary')
const failedStatus = new FileStatus()
failedStatus.phase = FileStatusPhase.FAILED
expect(component.getStatusColor(failedStatus)).toEqual('danger')
const successStatus = new FileStatus()
successStatus.phase = FileStatusPhase.SUCCESS
expect(component.getStatusColor(successStatus)).toEqual('success')
})
it('should allow dismissing an alert', () => {
const dismissSpy = jest.spyOn(websocketStatusService, 'dismiss')
component.dismiss(new FileStatus())
expect(dismissSpy).toHaveBeenCalled()
})
it('should allow dismissing completed alerts', () => {
jest.useFakeTimers()
mockConsumerStatuses(websocketStatusService)
fixture.detectChanges()
jest
.spyOn(component, 'getStatusCompleted')
.mockImplementation(() => SUCCESS_STATUSES)
const dismissSpy = jest.spyOn(websocketStatusService, 'dismiss')
component.dismissCompleted()
jest.advanceTimersByTime(1000)
fixture.detectChanges()
expect(dismissSpy).toHaveBeenCalledTimes(4)
jest.useRealTimers()
.spyOn(fileInput.nativeElement, 'files', 'get')
.mockReturnValue([file] as any)
fileInput.nativeElement.dispatchEvent(new Event('change'))
expect(uploadSpy).toHaveBeenCalledWith(file)
})
})
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, QueryList, ViewChildren, inject } from '@angular/core'
import { RouterModule } from '@angular/router'
import {
NgbAlert,
NgbAlertModule,
NgbProgressbarModule,
} from '@ng-bootstrap/ng-bootstrap'
import { Component, inject } from '@angular/core'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { SettingsService } from 'src/app/services/settings.service'
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
import {
FileStatus,
FileStatusPhase,
WebsocketStatusService,
} from 'src/app/services/websocket-status.service'
import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
@Component({
@@ -27,117 +13,16 @@ import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
imports: [
WidgetFrameComponent,
IfPermissionsDirective,
NgClass,
NgTemplateOutlet,
RouterModule,
NgbAlertModule,
NgbProgressbarModule,
NgxBootstrapIconsModule,
TourNgBootstrap,
],
})
export class UploadFileWidgetComponent extends ComponentWithPermissions {
private websocketStatusService = inject(WebsocketStatusService)
private uploadDocumentsService = inject(UploadDocumentsService)
settingsService = inject(SettingsService)
@ViewChildren(NgbAlert) alerts: QueryList<NgbAlert>
getStatus() {
return this.websocketStatusService.getConsumerStatus()
}
getStatusSummary() {
let strings = []
let countUploadingAndProcessing =
this.websocketStatusService.getConsumerStatusNotCompleted().length
let countFailed = this.getStatusFailed().length
let countSuccess = this.getStatusSuccess().length
if (countUploadingAndProcessing > 0) {
strings.push($localize`Processing: ${countUploadingAndProcessing}`)
}
if (countFailed > 0) {
strings.push($localize`Failed: ${countFailed}`)
}
if (countSuccess > 0) {
strings.push($localize`Added: ${countSuccess}`)
}
return strings.join(
$localize`:this string is used to separate processing, failed and added on the file upload widget:, `
)
}
getStatusUploading() {
return this.websocketStatusService.getConsumerStatus(
FileStatusPhase.UPLOADING
)
}
getStatusFailed() {
return this.websocketStatusService.getConsumerStatus(FileStatusPhase.FAILED)
}
getStatusSuccess() {
return this.websocketStatusService.getConsumerStatus(
FileStatusPhase.SUCCESS
)
}
getStatusCompleted() {
return this.websocketStatusService.getConsumerStatusCompleted()
}
getTotalUploadProgress() {
let current = 0
let max = 0
this.getStatusUploading().forEach((status) => {
current += status.currentPhaseProgress
max += status.currentPhaseMaxProgress
})
return current / Math.max(max, 1)
}
isFinished(status: FileStatus) {
return (
status.phase == FileStatusPhase.FAILED ||
status.phase == FileStatusPhase.SUCCESS
)
}
getStatusColor(status: FileStatus) {
switch (status.phase) {
case FileStatusPhase.UPLOADING:
case FileStatusPhase.STARTED:
case FileStatusPhase.WORKING:
return 'primary'
case FileStatusPhase.FAILED:
return 'danger'
case FileStatusPhase.SUCCESS:
return 'success'
}
}
dismiss(status: FileStatus) {
this.websocketStatusService.dismiss(status)
}
dismissCompleted() {
this.getStatusCompleted().forEach((status) =>
this.websocketStatusService.dismiss(status)
)
}
public onFileSelected(event: Event) {
const files = (event.target as HTMLInputElement).files
for (let i = 0; i < files?.length; i++) {
const file = files.item(i)
file && this.uploadDocumentsService.uploadFile(file)
}
}
get slimSidebarEnabled(): boolean {
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
const input = event.target as HTMLInputElement
this.uploadDocumentsService.uploadFiles(Array.from(input.files ?? []))
input.value = ''
}
}
@@ -1,25 +1,4 @@
<pngx-page-header [title]="title()" [id]="documentId()">
@if (archiveContentRenderType === ContentRenderType.PDF && !useNativePdfViewer) {
@if (previewNumPages()) {
<div class="input-group input-group-sm ms-2 d-none d-md-flex">
<div class="input-group-text" i18n>Page</div>
<input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewNumPages()" [ngModel]="previewCurrentPage()" (ngModelChange)="previewCurrentPage.set($event)" />
<div class="input-group-text" i18n>of {{previewNumPages()}}</div>
</div>
}
<div class="input-group input-group-sm me-md-5 d-none d-md-flex">
<button class="btn btn-outline-secondary" (click)="decreaseZoom()" i18n>-</button>
<select class="form-select" (change)="setZoom($event.target.value)" [ngModel]="currentZoom">
@for (setting of zoomSettings; track setting) {
<option [value]="setting">
{{ getZoomSettingTitle(setting) }}
</option>
}
</select>
<button class="btn btn-outline-secondary" (click)="increaseZoom()" i18n>+</button>
</div>
}
<button type="button" class="btn btn-sm btn-outline-danger me-md-4" (click)="delete()" [disabled]="!userIsOwner" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }" aria-label="Delete" i18n-aria-label>
<i-bs width="1.2em" height="1.2em" name="trash"></i-bs><span class="d-none d-lg-inline ps-1" i18n>Delete</span>
</button>
@@ -34,7 +13,7 @@
(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>
@if (downloading()) {
<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>
<i-bs name="send"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Send</ng-container></div>
</button>
<div ngbDropdownMenu aria-labelledby="actionsDropdown" class="shadow">
<div ngbDropdownMenu aria-labelledby="sendDropdown" class="shadow">
<button ngbDropdownItem (click)="openShareLinks()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.ShareLink }">
<i-bs name="link" class="me-1"></i-bs><span i18n>Share Links</span>
</button>
@@ -113,8 +92,8 @@
<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-group me-auto">
<div class="btn-toolbar mb-1 pb-3 gap-2 row-gap-2 border-bottom">
<div class="btn-group">
<button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Close" (click)="close()">
<i-bs width="1.2em" height="1.2em" name="x"></i-bs>
</button>
@@ -127,43 +106,30 @@
</div>
<ng-container *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
<div class="d-flex gap-2">
<div class="btn-group">
<pngx-suggestions-dropdown *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }"
[disabled]="!userCanEdit || suggestionsLoading()"
[loading]="suggestionsLoading()"
[suggestions]="suggestions()"
[aiEnabled]="aiEnabled"
[source]="suggestionSource"
[defaultSource]="defaultSuggestionSource"
[fetchedSources]="fetchedSuggestionSources()"
[appliedTags]="documentForm.value.tags"
[appliedCorrespondent]="documentForm.value.correspondent"
[appliedDocumentType]="documentForm.value.document_type"
[appliedStoragePath]="documentForm.value.storage_path"
[appliedTitle]="documentForm.value.title"
[appliedCreated]="documentForm.value.created"
(getSuggestions)="getSuggestions($event)"
(sourceChange)="suggestionSourceOverride.set($event)"
(addTag)="createTag($event)"
(addDocumentType)="createDocumentType($event)"
(addCorrespondent)="createCorrespondent($event)">
</pngx-suggestions-dropdown>
</div>
<div class="btn-group">
<pngx-custom-fields-dropdown
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }"
[documentId]="documentId()"
[disabled]="!userCanEdit"
[existingFields]="document()?.custom_fields"
(created)="refreshCustomFields()"
(added)="addField($event)">
</pngx-custom-fields-dropdown>
</div>
<div class="btn-group ms-3">
<pngx-suggestions-dropdown *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }"
[disabled]="!userCanEdit || suggestionsLoading()"
[loading]="suggestionsLoading()"
[suggestions]="suggestions()"
[aiEnabled]="aiEnabled"
[source]="suggestionSource"
[defaultSource]="defaultSuggestionSource"
[fetchedSources]="fetchedSuggestionSources()"
[appliedTags]="documentForm.value.tags"
[appliedCorrespondent]="documentForm.value.correspondent"
[appliedDocumentType]="documentForm.value.document_type"
[appliedStoragePath]="documentForm.value.storage_path"
[appliedTitle]="documentForm.value.title"
[appliedCreated]="documentForm.value.created"
(getSuggestions)="getSuggestions($event)"
(sourceChange)="suggestionSourceOverride.set($event)"
(addTag)="createTag($event)"
(addDocumentType)="createDocumentType($event)"
(addCorrespondent)="createCorrespondent($event)">
</pngx-suggestions-dropdown>
</div>
<div class="ps-3">
<div class="ms-auto">
<ng-container *ngTemplateOutlet="saveButtons"></ng-container>
</div>
</ng-container>
@@ -290,8 +256,20 @@
}
</div>
<div class="d-flex justify-content-end border-top pt-3">
<ng-container *ngTemplateOutlet="saveButtons"></ng-container>
<div class="d-flex border-top pt-3">
<ng-container *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
<pngx-custom-fields-dropdown
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }"
[documentId]="documentId()"
[disabled]="!userCanEdit"
[existingFields]="document()?.custom_fields"
(created)="refreshCustomFields()"
(added)="addField($event)">
</pngx-custom-fields-dropdown>
</ng-container>
<div class="ms-auto">
<ng-container *ngTemplateOutlet="saveButtons"></ng-container>
</div>
</div>
</ng-template>
</li>
@@ -328,9 +306,9 @@
<td i18n>Original filename</td>
<td>{{metadata()?.original_filename}}</td>
</tr>
<tr>
<tr class="align-middle">
<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>
<td i18n>Original file size</td>
@@ -341,9 +319,9 @@
<td>{{metadata()?.original_mime_type}}</td>
</tr>
@if (metadata()?.has_archive_version) {
<tr>
<tr class="align-middle">
<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>
}
@if (metadata()?.has_archive_version) {
@@ -408,7 +386,7 @@
<a ngbNavLink i18n>Permissions</a>
<ng-template ngbNavContent>
<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>
</ng-template>
</li>
@@ -499,6 +477,29 @@
(loadError)="onError($event)"
(afterLoadComplete)="pdfPreviewLoaded($event)">
</pngx-pdf-viewer>
<div class="position-absolute bottom-0 start-50 translate-middle-x z-1 mb-3 d-flex gap-2 p-1 bg-body border rounded shadow">
@if (previewNumPages()) {
<div class="input-group input-group-sm w-auto flex-nowrap">
<input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewNumPages()" [ngModelOptions]="{standalone: true}" [ngModel]="previewCurrentPage()" (ngModelChange)="previewCurrentPage.set($event)" aria-label="Page" i18n-aria-label />
<div class="input-group-text" i18n>of {{previewNumPages()}}</div>
</div>
}
<div class="input-group input-group-sm w-auto flex-nowrap">
<button class="btn btn-outline-secondary" (click)="decreaseZoom()" aria-label="Zoom out" i18n-aria-label>
<i-bs width="1em" height="1em" name="dash"></i-bs>
</button>
<select class="form-select" (change)="setZoom($event.target.value)" [ngModelOptions]="{standalone: true}" [ngModel]="currentZoom" aria-label="Zoom" i18n-aria-label>
@for (setting of zoomSettings; track setting) {
<option [value]="setting">
{{ getZoomSettingTitle(setting) }}
</option>
}
</select>
<button class="btn btn-outline-secondary" (click)="increaseZoom()" aria-label="Zoom in" i18n-aria-label>
<i-bs width="1em" height="1em" name="plus"></i-bs>
</button>
</div>
</div>
</div>
} @else {
<object [data]="previewUrl() | safeUrl" class="preview-sticky" width="100%"></object>
@@ -60,6 +60,7 @@ import { TagService } from 'src/app/services/rest/tag.service'
import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { TrashService } from 'src/app/services/trash.service'
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
import { environment } from 'src/environments/environment'
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
@@ -137,6 +138,7 @@ describe('DocumentDetailComponent', () => {
let openDocumentsService: OpenDocumentsService
let modalService: NgbModal
let toastService: ToastService
let trashService: TrashService
let documentListViewService: DocumentListViewService
let settingsService: SettingsService
let customFieldsService: CustomFieldsService
@@ -285,6 +287,7 @@ describe('DocumentDetailComponent', () => {
documentService = TestBed.inject(DocumentService)
modalService = TestBed.inject(NgbModal)
toastService = TestBed.inject(ToastService)
trashService = TestBed.inject(TrashService)
documentListViewService = TestBed.inject(DocumentListViewService)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser.set({ id: 1 })
@@ -775,7 +778,7 @@ describe('DocumentDetailComponent', () => {
component.title.set('Foo Bar')
const closeSpy = jest.spyOn(component, 'close')
const patchSpy = jest.spyOn(documentService, 'patch')
const toastSpy = jest.spyOn(toastService, 'showInfo')
const toastSpy = jest.spyOn(toastService, 'showSuccess')
patchSpy.mockImplementation((o) => of(doc))
component.save(true)
expect(patchSpy).toHaveBeenCalled()
@@ -790,7 +793,7 @@ describe('DocumentDetailComponent', () => {
component.title.set('Foo Bar')
const closeSpy = jest.spyOn(component, 'close')
const patchSpy = jest.spyOn(documentService, 'patch')
const toastSpy = jest.spyOn(toastService, 'showInfo')
const toastSpy = jest.spyOn(toastService, 'showSuccess')
patchSpy.mockImplementation((o) => of(doc))
component.save()
expect(patchSpy).toHaveBeenCalled()
@@ -836,7 +839,7 @@ describe('DocumentDetailComponent', () => {
component.title.set('Foo Bar')
const closeSpy = jest.spyOn(component, 'close')
const patchSpy = jest.spyOn(documentService, 'patch')
const toastSpy = jest.spyOn(toastService, 'showInfo')
const toastSpy = jest.spyOn(toastService, 'showSuccess')
patchSpy.mockImplementation(() =>
throwError(() => new Error('failed to save'))
)
@@ -957,12 +960,16 @@ describe('DocumentDetailComponent', () => {
const modalSpy = jest.spyOn(modalService, 'open')
const deleteSpy = jest.spyOn(documentService, 'delete')
deleteSpy.mockReturnValue(of(true))
const notifySpy = jest
.spyOn(trashService, 'notifyMovedToTrash')
.mockImplementation(() => {})
component.delete()
expect(modalSpy).toHaveBeenCalled()
const modalCloseSpy = jest.spyOn(openModal, 'close')
openModal.componentInstance.confirmClicked.next()
expect(deleteSpy).toHaveBeenCalled()
expect(modalCloseSpy).toHaveBeenCalled()
expect(notifySpy).toHaveBeenCalledWith(expect.any(String), [doc.id])
})
it('should allow retry delete if error', () => {
@@ -95,6 +95,7 @@ import { TagService } from 'src/app/services/rest/tag.service'
import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { TrashService } from 'src/app/services/trash.service'
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
import { getFilenameFromContentDisposition } from 'src/app/utils/http'
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
@@ -221,6 +222,7 @@ export class DocumentDetailComponent
private documentListViewService = inject(DocumentListViewService)
private documentTitlePipe = inject(DocumentTitlePipe)
private toastService = inject(ToastService)
private trashService = inject(TrashService)
private settings = inject(SettingsService)
private storagePathService = inject(StoragePathService)
private permissionsService = inject(PermissionsService)
@@ -1349,7 +1351,7 @@ export class DocumentDetailComponent
this.store.next(newValues)
this.openDocumentService.setDirty(this.document(), false)
this.openDocumentService.save()
this.toastService.showInfo(
this.toastService.showSuccess(
$localize`Document "${newValues.title}" saved successfully.`
)
this.networkActive.set(false)
@@ -1377,7 +1379,7 @@ export class DocumentDetailComponent
if (!canEdit) {
// document was 'given away'
this.openDocumentService.setDirty(this.document(), false)
this.toastService.showInfo(
this.toastService.showSuccess(
$localize`Document "${this.document().title}" saved successfully.`
)
this.close()
@@ -1490,6 +1492,10 @@ export class DocumentDetailComponent
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({
next: () => {
this.trashService.notifyMovedToTrash(
$localize`Moved "${this.document().title}" to the trash`,
[this.document().id]
)
modal.close()
this.close()
},
@@ -33,7 +33,9 @@ describe('DocumentVersionDropdownComponent', () => {
| 'updateVersionLabel'
>
>
let toastService: jest.Mocked<Pick<ToastService, 'showError' | 'showInfo'>>
let toastService: jest.Mocked<
Pick<ToastService, 'showError' | 'showInfo' | 'showSuccess'>
>
let finished$: Subject<{ taskId: string }>
let failed$: Subject<{ taskId: string; message?: string }>
let modalService: jest.Mocked<Pick<NgbModal, 'open'>>
@@ -55,6 +57,7 @@ describe('DocumentVersionDropdownComponent', () => {
toastService = {
showError: jest.fn(),
showInfo: jest.fn(),
showSuccess: jest.fn(),
}
permissionsService = {
currentUserCan: jest.fn().mockReturnValue(true),
@@ -416,6 +419,6 @@ describe('DocumentVersionDropdownComponent', () => {
expect(selectedEmitSpy).toHaveBeenCalledWith(2)
expect(component.newVersionLabel).toEqual('')
expect(modal.close).toHaveBeenCalled()
expect(toastService.showInfo).toHaveBeenCalled()
expect(toastService.showSuccess).toHaveBeenCalled()
})
})
@@ -320,7 +320,7 @@ export class DocumentVersionDropdownComponent
}
this.newVersionLabel = ''
modal.close()
this.toastService.showInfo(
this.toastService.showSuccess(
$localize`Existing document added as a version.`
)
},
@@ -39,6 +39,7 @@ import { TagService } from 'src/app/services/rest/tag.service'
import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { TrashService } from 'src/app/services/trash.service'
import { environment } from 'src/environments/environment'
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
@@ -74,6 +75,7 @@ describe('BulkEditorComponent', () => {
let documentListViewService: DocumentListViewService
let documentService: DocumentService
let toastService: ToastService
let trashService: TrashService
let modalService: NgbModal
let tagService: TagService
let correspondentsService: CorrespondentService
@@ -185,6 +187,7 @@ describe('BulkEditorComponent', () => {
documentListViewService = TestBed.inject(DocumentListViewService)
documentService = TestBed.inject(DocumentService)
toastService = TestBed.inject(ToastService)
trashService = TestBed.inject(TrashService)
modalService = TestBed.inject(NgbModal)
tagService = TestBed.inject(TagService)
correspondentsService = TestBed.inject(CorrespondentService)
@@ -1164,10 +1167,14 @@ describe('BulkEditorComponent', () => {
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/delete/`
)
const notifySpy = jest
.spyOn(trashService, 'notifyMovedToTrash')
.mockImplementation(() => {})
req.flush(true)
expect(req.request.body).toEqual({
documents: [3, 4],
})
expect(notifySpy).toHaveBeenCalledWith(expect.any(String), [3, 4])
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
@@ -1177,10 +1184,19 @@ describe('BulkEditorComponent', () => {
component.showConfirmationDialogs = false
fixture.detectChanges()
jest
.spyOn(documentListViewService, 'allSelected', 'get')
.mockReturnValue(true)
component.applyDelete()
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/delete/`
)
req.flush(true)
// ids aren't known when all matching documents are selected, so no undo
expect(notifySpy).toHaveBeenLastCalledWith(expect.any(String), null)
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
})
it('should not be accessible with insufficient global permissions', () => {
@@ -1416,7 +1432,7 @@ describe('BulkEditorComponent', () => {
const mergeAsVersionsSpy = jest
.spyOn(documentService, 'mergeDocumentsAsVersions')
.mockReturnValue(of(true))
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
fixture.detectChanges()
component.mergeSelectedAsVersions()
@@ -1432,7 +1448,9 @@ describe('BulkEditorComponent', () => {
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
)
expect(documentListViewService.selected.size).toEqual(0)
expect(toastInfoSpy).toHaveBeenCalledWith('Documents merged as versions.')
expect(toastSuccessSpy).toHaveBeenCalledWith(
'Documents merged as versions.'
)
})
it('should not report success when merging documents as versions fails', () => {
@@ -1458,7 +1476,7 @@ describe('BulkEditorComponent', () => {
jest
.spyOn(documentService, 'mergeDocumentsAsVersions')
.mockReturnValue(throwError(() => new Error('failed')))
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
const toastErrorSpy = jest.spyOn(toastService, 'showError')
fixture.detectChanges()
@@ -1467,7 +1485,7 @@ describe('BulkEditorComponent', () => {
modal.componentInstance.confirm()
expect(toastErrorSpy).toHaveBeenCalled()
expect(toastInfoSpy).not.toHaveBeenCalled()
expect(toastSuccessSpy).not.toHaveBeenCalled()
})
it('should support bulk download with archive, originals or both and file formatting', () => {
@@ -1846,7 +1864,7 @@ describe('BulkEditorComponent', () => {
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([3, 4]))
fixture.detectChanges()
const toastServiceShowInfoSpy = jest.spyOn(toastService, 'showInfo')
const toastServiceShowInfoSpy = jest.spyOn(toastService, 'showSuccess')
const toastServiceShowErrorSpy = jest.spyOn(toastService, 'showError')
const listReloadSpy = jest.spyOn(documentListViewService, 'reload')
@@ -20,7 +20,15 @@ import {
} from '@ng-bootstrap/ng-bootstrap'
import { saveAs } from 'file-saver'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { first, map, Observable, Subject, switchMap, takeUntil } from 'rxjs'
import {
first,
map,
Observable,
Subject,
switchMap,
takeUntil,
tap,
} from 'rxjs'
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
import { CustomField } from 'src/app/data/custom-field'
import { MatchingModel } from 'src/app/data/matching-model'
@@ -49,6 +57,7 @@ import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { TagService } from 'src/app/services/rest/tag.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { TrashService } from 'src/app/services/trash.service'
import { flattenTags } from 'src/app/utils/flatten-tags'
import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
import { MergeAsVersionsConfirmDialogComponent } from '../../common/confirm-dialog/merge-as-versions-confirm-dialog/merge-as-versions-confirm-dialog.component'
@@ -99,6 +108,7 @@ export class BulkEditorComponent
private openDocumentService = inject(OpenDocumentsService)
private settings = inject(SettingsService)
private toastService = inject(ToastService)
private trashService = inject(TrashService)
private storagePathService = inject(StoragePathService)
private customFieldService = inject(CustomFieldsService)
public readonly permissionService = inject(PermissionsService)
@@ -306,7 +316,7 @@ export class BulkEditorComponent
next: () => {
this.handleOperationSuccess(modal, options.clearSelection ?? false)
if (options.successMessage) {
this.toastService.showInfo(options.successMessage)
this.toastService.showSuccess(options.successMessage)
}
},
error: (error) => this.handleOperationError(modal, error),
@@ -884,19 +894,33 @@ export class BulkEditorComponent
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
this.executeDocumentAction(
modal,
this.documentService.deleteDocuments(this.getSelectionQuery())
)
this.deleteSelected(modal)
})
} else {
this.executeDocumentAction(
null,
this.documentService.deleteDocuments(this.getSelectionQuery())
)
this.deleteSelected(null)
}
}
private deleteSelected(modal: NgbModalRef) {
const documents = this.list.allSelected
? null
: Array.from(this.list.selected)
const count = this.getSelectionSize()
this.executeDocumentAction(
modal,
this.documentService
.deleteDocuments(this.getSelectionQuery())
.pipe(
tap(() =>
this.trashService.notifyMovedToTrash(
$localize`Moved ${count} document(s) to the trash`,
documents
)
)
)
)
}
downloadSelected() {
this.awaitingDownload.set(true)
let downloadFileType: string =
@@ -1065,7 +1089,7 @@ export class BulkEditorComponent
dialog.selection = this.getSelectionQuery()
dialog.selectionCount = this.getSelectionSize()
dialog.succeeded.subscribe((result) => {
this.toastService.showInfo($localize`Custom fields updated.`)
this.toastService.showSuccess($localize`Custom fields updated.`)
this.list.reload()
this.list.reduceSelectionToFilter()
this.list.selected.forEach((id) => {
@@ -487,7 +487,7 @@ describe('DocumentListComponent', () => {
delete modifiedView.name
const savedViewServicePatch = jest.spyOn(savedViewService, 'patch')
savedViewServicePatch.mockReturnValue(of(modifiedView))
const toastSpy = jest.spyOn(toastService, 'showInfo')
const toastSpy = jest.spyOn(toastService, 'showSuccess')
component.saveViewConfig()
expect(savedViewServicePatch).toHaveBeenCalledWith(modifiedView)
@@ -592,7 +592,7 @@ describe('DocumentListComponent', () => {
let openModal: NgbModalRef
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
const modalSpy = jest.spyOn(modalService, 'open')
const toastSpy = jest.spyOn(toastService, 'showInfo')
const toastSpy = jest.spyOn(toastService, 'showSuccess')
const savedViewServiceCreate = jest.spyOn(savedViewService, 'create')
jest
.spyOn(savedViewService, 'dashboardViews', 'get')
@@ -418,7 +418,7 @@ export class DocumentListComponent
next: (view) => {
this.activeSavedView = view
this.unmodifiedSavedView = view
this.toastService.showInfo(
this.toastService.showSuccess(
$localize`View "${this.list.activeSavedViewTitle}" saved successfully.`
)
this.unmodifiedFilterRules.set(this.list.filterRules)
@@ -493,7 +493,7 @@ export class DocumentListComponent
.subscribe({
next: () => {
modal.close()
this.toastService.showInfo(
this.toastService.showSuccess(
$localize`View "${savedView.name}" created successfully.`
)
},
@@ -135,7 +135,7 @@ export class FileDropComponent {
.then((results) => {
files.push(...[].concat(...results))
this.toastService.showInfo($localize`Initiating upload...`, 3000)
files.forEach((file) => this.uploadDocumentsService.uploadFile(file))
this.uploadDocumentsService.uploadFiles(files)
})
.catch((e) => {
this.toastService.showError(
@@ -144,7 +144,7 @@ export class FileDropComponent {
})
} else if (files.length) {
this.toastService.showInfo($localize`Initiating upload...`, 3000)
files.forEach((file) => this.uploadDocumentsService.uploadFile(file))
this.uploadDocumentsService.uploadFiles(files)
}
this.onDragLeave(event, true)
@@ -107,7 +107,7 @@ describe('CustomFieldsComponent', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const reloadSpy = jest.spyOn(component, 'reload')
component.editField()
@@ -131,7 +131,7 @@ describe('CustomFieldsComponent', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const reloadSpy = jest.spyOn(component, 'reload')
const editButton = fixture.debugElement
@@ -82,7 +82,9 @@ export class CustomFieldsComponent
modal.componentInstance.succeeded
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((newField) => {
this.toastService.showInfo($localize`Saved field "${newField.name}".`)
this.toastService.showSuccess(
$localize`Saved field "${newField.name}".`
)
this.customFieldsService.clearCache()
this.settingsService.initializeDisplayFields()
this.documentService.reload()
@@ -109,7 +111,9 @@ export class CustomFieldsComponent
this.customFieldsService.delete(field).subscribe({
next: () => {
modal.close()
this.toastService.showInfo($localize`Deleted field "${field.name}"`)
this.toastService.showSuccess(
$localize`Deleted field "${field.name}"`
)
this.customFieldsService.clearCache()
this.settingsService.initializeDisplayFields()
this.documentService.reload()
@@ -160,7 +160,7 @@ describe('ManagementListComponent', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const reloadSpy = jest.spyOn(component, 'reloadData')
component.openCreateDialog()
@@ -183,7 +183,7 @@ describe('ManagementListComponent', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const reloadSpy = jest.spyOn(component, 'reloadData')
component.openEditDialog(tags[0])
@@ -387,7 +387,7 @@ describe('ManagementListComponent', () => {
expect(bulkEditPermsSpy).toHaveBeenCalled()
expect(errorToastSpy).toHaveBeenCalled()
const successToastSpy = jest.spyOn(toastService, 'showInfo')
const successToastSpy = jest.spyOn(toastService, 'showSuccess')
bulkEditPermsSpy.mockReturnValueOnce(of('OK'))
modal.componentInstance.confirmClicked.emit({
permissions: {},
@@ -452,7 +452,7 @@ describe('ManagementListComponent', () => {
)
expect(errorToastSpy).toHaveBeenCalled()
const successToastSpy = jest.spyOn(toastService, 'showInfo')
const successToastSpy = jest.spyOn(toastService, 'showSuccess')
bulkEditSpy.mockReturnValueOnce(of('OK'))
modal.componentInstance.confirmClicked.emit(null)
expect(bulkEditSpy).toHaveBeenCalled()
@@ -224,7 +224,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
activeModal.componentInstance.dialogMode.set(EditDialogMode.CREATE)
activeModal.componentInstance.succeeded.subscribe(() => {
this.reloadData()
this.toastService.showInfo(
this.toastService.showSuccess(
$localize`Successfully created ${this.typeName}.`
)
})
@@ -244,7 +244,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
activeModal.componentInstance.dialogMode.set(EditDialogMode.EDIT)
activeModal.componentInstance.succeeded.subscribe(() => {
this.reloadData()
this.toastService.showInfo(
this.toastService.showSuccess(
$localize`Successfully updated ${this.typeName} "${object.name}".`
)
})
@@ -428,7 +428,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
.subscribe({
next: () => {
modal.close()
this.toastService.showInfo(
this.toastService.showSuccess(
$localize`Permissions updated successfully`
)
this.reloadData()
@@ -468,7 +468,9 @@ export abstract class ManagementListComponent<T extends MatchingModel>
.subscribe({
next: () => {
modal.close()
this.toastService.showInfo($localize`Objects deleted successfully`)
this.toastService.showSuccess(
$localize`Objects deleted successfully`
)
this.reloadData()
},
error: (error) => {
@@ -184,7 +184,7 @@ describe('MailComponent', () => {
component.editMailAccount(mailAccounts[0] as MailAccount)
let editDialog = modal.componentInstance as MailAccountEditDialogComponent
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
editDialog.failed.emit()
expect(toastErrorSpy).toHaveBeenCalled()
editDialog.succeeded.emit(mailAccounts[0] as any)
@@ -203,7 +203,7 @@ describe('MailComponent', () => {
const deleteDialog = modal.componentInstance as ConfirmDialogComponent
const deleteSpy = jest.spyOn(mailAccountService, 'delete')
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const listAllSpy = jest.spyOn(mailAccountService, 'listAll')
deleteSpy.mockReturnValueOnce(
throwError(() => new Error('error deleting mail account'))
@@ -242,7 +242,7 @@ describe('MailComponent', () => {
component.editMailRule(mailRules[0] as MailRule)
const editDialog = modal.componentInstance as MailRuleEditDialogComponent
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
editDialog.failed.emit()
expect(toastErrorSpy).toHaveBeenCalled()
editDialog.succeeded.emit(mailRules[0] as any)
@@ -272,7 +272,7 @@ describe('MailComponent', () => {
const deleteDialog = modal.componentInstance as ConfirmDialogComponent
const deleteSpy = jest.spyOn(mailRuleService, 'delete')
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const listAllSpy = jest.spyOn(mailRuleService, 'listAll')
deleteSpy.mockReturnValueOnce(
throwError(() => new Error('error deleting mail rule "rule1"'))
@@ -303,7 +303,7 @@ describe('MailComponent', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const rulePatchSpy = jest.spyOn(mailRuleService, 'patch')
component.editPermissions(mailRules[0] as MailRule)
expect(modal).not.toBeUndefined()
@@ -356,7 +356,7 @@ describe('MailComponent', () => {
By.css('input[type="checkbox"]')
)
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
// fail first
patchSpy.mockReturnValueOnce(
throwError(() => new Error('Error getting config'))
@@ -380,7 +380,7 @@ describe('MailComponent', () => {
jest
.spyOn(activatedRoute, 'queryParamMap', 'get')
.mockReturnValue(of(convertToParamMap(queryParams)))
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
completeSetup()
expect(toastInfoSpy).toHaveBeenCalled()
})
@@ -135,7 +135,9 @@ export class MailComponent
if (params.get('oauth_success')) {
const success = params.get('oauth_success') === '1'
if (success) {
this.toastService.showInfo($localize`OAuth2 authentication success`)
this.toastService.showSuccess(
$localize`OAuth2 authentication success`
)
this.oAuthAccountId = parseInt(params.get('account_id'))
if (this.mailAccounts().length > 0) {
this.editMailAccount(
@@ -169,7 +171,7 @@ export class MailComponent
modal.componentInstance.succeeded
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((newMailAccount) => {
this.toastService.showInfo(
this.toastService.showSuccess(
$localize`Saved account "${newMailAccount.name}".`
)
this.mailAccountService.clearCache()
@@ -200,7 +202,7 @@ export class MailComponent
this.mailAccountService.delete(account).subscribe({
next: () => {
modal.close()
this.toastService.showInfo(
this.toastService.showSuccess(
$localize`Deleted mail account "${account.name}"`
)
this.mailAccountService.clearCache()
@@ -248,7 +250,9 @@ export class MailComponent
modal.componentInstance.succeeded
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((newMailRule) => {
this.toastService.showInfo($localize`Saved rule "${newMailRule.name}".`)
this.toastService.showSuccess(
$localize`Saved rule "${newMailRule.name}".`
)
this.mailRuleService.clearCache()
this.mailRuleService
.listAll(null, null, { full_perms: true })
@@ -273,7 +277,7 @@ export class MailComponent
onMailRuleEnableToggled(rule: MailRule) {
this.mailRuleService.patch(rule).subscribe({
next: () => {
this.toastService.showInfo(
this.toastService.showSuccess(
rule.enabled
? $localize`Rule "${rule.name}" enabled.`
: $localize`Rule "${rule.name}" disabled.`
@@ -302,7 +306,7 @@ export class MailComponent
this.mailRuleService.delete(rule).subscribe({
next: () => {
modal.close()
this.toastService.showInfo(
this.toastService.showSuccess(
$localize`Deleted mail rule "${rule.name}"`
)
this.mailRuleService.clearCache()
@@ -338,7 +342,7 @@ export class MailComponent
object['set_permissions'] = permissions['set_permissions']
service.patch(object).subscribe({
next: () => {
this.toastService.showInfo($localize`Permissions updated`)
this.toastService.showSuccess($localize`Permissions updated`)
modal.close()
},
error: (e) => {
@@ -96,7 +96,7 @@ describe('ProcessedMailDialogComponent', () => {
// select a couple of mails and delete
component.selectedMailIds().add(5)
component.selectedMailIds().add(6)
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
component.deleteSelected()
const delReq = httpTestingController.expectOne(
@@ -67,7 +67,7 @@ export class ProcessedMailDialogComponent implements OnInit {
this.processedMailService
.bulk_delete(Array.from(this.selectedMailIds()))
.subscribe(() => {
this.toastService.showInfo($localize`Processed mail(s) deleted`)
this.toastService.showSuccess($localize`Processed mail(s) deleted`)
this.loadProcessedMails()
})
}
@@ -227,7 +227,7 @@ describe('SavedViewsComponent', () => {
})
it('should support delete saved view', () => {
const toastSpy = jest.spyOn(toastService, 'showInfo')
const toastSpy = jest.spyOn(toastService, 'showSuccess')
const deleteSpy = jest.spyOn(savedViewService, 'delete')
deleteSpy.mockReturnValue(of(true))
component.deleteSavedView(savedViews[0] as SavedView)
@@ -183,7 +183,7 @@ export class SavedViewsComponent
this.savedViews.update((savedViews) =>
savedViews.filter((view) => view !== savedView)
)
this.toastService.showInfo(
this.toastService.showSuccess(
$localize`Saved view "${savedView.name}" deleted.`
)
this.savedViewService.clearCache()
@@ -262,7 +262,7 @@ export class SavedViewsComponent
saveOperation.subscribe({
next: () => {
this.toastService.showInfo($localize`Views saved successfully.`)
this.toastService.showSuccess($localize`Views saved successfully.`)
this.savedViewService.clearCache()
this.reloadViews()
},
@@ -315,7 +315,7 @@ export class SavedViewsComponent
view['set_permissions'] = permissions.set_permissions
this.savedViewService.patch(view as SavedView).subscribe({
next: () => {
this.toastService.showInfo($localize`Permissions updated`)
this.toastService.showSuccess($localize`Permissions updated`)
modal.close()
this.reloadViews()
},
@@ -22,6 +22,7 @@ class MockShareLinkBundleService {
class MockToastService {
showInfo = jest.fn()
showSuccess = jest.fn()
showError = jest.fn()
}
@@ -210,7 +211,7 @@ describe('ShareLinkBundleListComponent', () => {
component.delete(sampleBundle())
expect(service.delete).toHaveBeenCalled()
expect(toastService.showInfo).toHaveBeenCalledWith(
expect(toastService.showSuccess).toHaveBeenCalledWith(
expect.stringContaining('deleted.')
)
expect(service.list).toHaveBeenCalledTimes(2)
@@ -178,7 +178,7 @@ export class ShareLinkBundleListComponent
if (this.bundles().length === 1 && this.page() > 1) {
this.page.update((page) => page - 1)
}
this.toastService.showInfo($localize`Share link bundle deleted.`)
this.toastService.showSuccess($localize`Share link bundle deleted.`)
this.triggerRefresh(false)
},
error: (e) => {
@@ -15,7 +15,7 @@ describe('ShareLinkListComponent', () => {
let fixture: ComponentFixture<ShareLinkListComponent>
let service: jest.Mocked<Pick<ShareLinkService, 'list' | 'delete'>>
let clipboard: Clipboard
let toastService: jest.Mocked<Pick<ToastService, 'showInfo' | 'showError'>>
let toastService: jest.Mocked<Pick<ToastService, 'showSuccess' | 'showError'>>
const link = {
id: 1,
@@ -33,7 +33,7 @@ describe('ShareLinkListComponent', () => {
delete: jest.fn().mockReturnValue(of(true)),
}
toastService = {
showInfo: jest.fn(),
showSuccess: jest.fn(),
showError: jest.fn(),
}
@@ -130,7 +130,7 @@ describe('ShareLinkListComponent', () => {
expect(component.copiedID()).toBe(link.id)
expect(fixture.nativeElement.querySelector('.badge.show')).not.toBeNull()
expect(toastService.showInfo).not.toHaveBeenCalled()
expect(toastService.showSuccess).not.toHaveBeenCalled()
jest.advanceTimersByTime(3000)
expect(component.copiedID()).toBeNull()
@@ -142,7 +142,7 @@ describe('ShareLinkListComponent', () => {
expect(service.delete).toHaveBeenCalledWith(link)
expect(service.list).toHaveBeenCalledTimes(2)
expect(toastService.showInfo).toHaveBeenCalled()
expect(toastService.showSuccess).toHaveBeenCalled()
})
it('shows an error when loading fails', () => {
@@ -151,7 +151,7 @@ export class ShareLinkListComponent
if (this.links().length === 1 && this.page() > 1) {
this.page.update((page) => page - 1)
}
this.toastService.showInfo($localize`Share link deleted.`)
this.toastService.showSuccess($localize`Share link deleted.`)
this.reload()
},
error: (error) => {
@@ -128,7 +128,7 @@ describe('WorkflowsComponent', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const reloadSpy = jest.spyOn(component, 'reload')
const createButton = fixture.debugElement.queryAll(By.css('button'))[1]
@@ -152,7 +152,7 @@ describe('WorkflowsComponent', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const reloadSpy = jest.spyOn(component, 'reload')
const editButton = fixture.debugElement.queryAll(By.css('button'))[2]
@@ -268,7 +268,7 @@ describe('WorkflowsComponent', () => {
By.css('input[type="checkbox"]')
)
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
// fail first
patchSpy.mockReturnValueOnce(
throwError(() => new Error('Error getting config'))
@@ -86,7 +86,7 @@ export class WorkflowsComponent
modal.componentInstance.succeeded
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((newWorkflow) => {
this.toastService.showInfo(
this.toastService.showSuccess(
$localize`Saved workflow "${newWorkflow.name}".`
)
this.workflowService.clearCache()
@@ -138,7 +138,7 @@ export class WorkflowsComponent
this.workflowService.delete(workflow).subscribe({
next: () => {
modal.close()
this.toastService.showInfo(
this.toastService.showSuccess(
$localize`Deleted workflow "${workflow.name}".`
)
this.workflowService.clearCache()
@@ -157,7 +157,7 @@ export class WorkflowsComponent
toggleWorkflowEnabled(workflow: Workflow) {
this.workflowService.patch(workflow).subscribe({
next: () => {
this.toastService.showInfo(
this.toastService.showSuccess(
workflow.enabled
? $localize`Enabled workflow "${workflow.name}"`
: $localize`Disabled workflow "${workflow.name}"`
-14
View File
@@ -62,14 +62,10 @@ export const SETTINGS_KEYS = {
'general-settings:document-details:pdf-viewer-zoom-setting',
DATE_LOCALE: 'general-settings:date-display:date-locale',
DATE_FORMAT: 'general-settings:date-display:date-format',
NOTIFICATIONS_CONSUMER_NEW_DOCUMENT:
'general-settings:notifications:consumer-new-documents',
NOTIFICATIONS_CONSUMER_SUCCESS:
'general-settings:notifications:consumer-success',
NOTIFICATIONS_CONSUMER_FAILED:
'general-settings:notifications:consumer-failed',
NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD:
'general-settings:notifications:consumer-suppress-on-dashboard',
NOTES_ENABLED: 'general-settings:notes-enabled',
AUDITLOG_ENABLED: 'general-settings:auditlog-enabled',
SLIM_SIDEBAR: 'general-settings:slim-sidebar',
@@ -197,11 +193,6 @@ export const SETTINGS: UiSetting[] = [
type: 'string',
default: 'mediumDate',
},
{
key: SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT,
type: 'boolean',
default: true,
},
{
key: SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS,
type: 'boolean',
@@ -212,11 +203,6 @@ export const SETTINGS: UiSetting[] = [
type: 'boolean',
default: true,
},
{
key: SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD,
type: 'boolean',
default: true,
},
{
key: SETTINGS_KEYS.NOTES_ENABLED,
type: 'boolean',
@@ -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.get(SETTINGS_KEYS.DARK_MODE_ENABLED)).toBeFalsy()
expect(
settingsService.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT)
settingsService.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS)
).toBeTruthy()
expect(settingsService.get(SETTINGS_KEYS.DOCUMENT_LIST_SIZE)).toEqual(50)
expect(settingsService.get(SETTINGS_KEYS.THEME_COLOR)).toEqual('#9fbf2f')
@@ -198,17 +198,14 @@ describe('SettingsService', () => {
settingsService.setLanguage('de-de')
settingsService.set(SETTINGS_KEYS.DARK_MODE_ENABLED, true)
settingsService.set(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT,
false
)
settingsService.set(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS, false)
settingsService.set(SETTINGS_KEYS.DOCUMENT_LIST_SIZE, 25)
settingsService.set(SETTINGS_KEYS.THEME_COLOR, '#000000')
expect(settingsService.getLanguage()).toEqual('de-de')
expect(settingsService.get(SETTINGS_KEYS.DARK_MODE_ENABLED)).toBeTruthy()
expect(
settingsService.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT)
settingsService.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS)
).toBeFalsy()
expect(settingsService.get(SETTINGS_KEYS.DOCUMENT_LIST_SIZE)).toEqual(25)
expect(settingsService.get(SETTINGS_KEYS.THEME_COLOR)).toEqual('#000000')
+5 -4
View File
@@ -679,7 +679,7 @@ export class SettingsService {
.subscribe({
next: () => {
this.updateAppearanceSettings()
this.toastService.showInfo(successMessage)
this.toastService.showSuccess(successMessage)
},
error: (e) => {
this.toastService.showError(errorMessage)
@@ -741,9 +741,10 @@ export class SettingsService {
this.storeSettings()
.pipe(first())
.subscribe(() => {
this.toastService.showInfo(
$localize`You can restart the tour from the settings page.`
)
this.toastService.show({
content: $localize`You can restart the tour from the settings page.`,
delay: 5000,
})
})
}
}
+90 -1
View File
@@ -1,5 +1,5 @@
import { TestBed } from '@angular/core/testing'
import { ToastService } from './toast.service'
import { Toast, ToastLevel, ToastService } from './toast.service'
describe('ToastService', () => {
let toastService: ToastService
@@ -68,6 +68,53 @@ describe('ToastService', () => {
})
})
it('keeps showSuccess toasts out of the toast list', () => {
let toasts: Toast[] = []
const popups: Toast[] = []
toastService.getToasts().subscribe((t) => (toasts = [...t]))
toastService.showToast.subscribe((t) => popups.push(t))
toastService.showSuccess('Saved')
expect(popups.map((t) => t.content)).toEqual(['Saved'])
expect(toasts).toHaveLength(0)
})
it('keeps showInfo toasts out of the toast list', () => {
let toasts: Toast[] = []
const popups: Toast[] = []
toastService.getToasts().subscribe((t) => (toasts = [...t]))
toastService.showToast.subscribe((t) => popups.push(t))
toastService.showInfo('Info toast')
toastService.showError('Error toast')
expect(popups.map((t) => t.content)).toEqual(['Info toast', 'Error toast'])
expect(toasts.map((t) => t.content)).toEqual(['Error toast'])
})
it('adds toasts to the list unless persistent is false', () => {
let toasts: Toast[] = []
toastService.getToasts().subscribe((t) => (toasts = [...t]))
toastService.show({ content: 'kept', delay: 5000 })
toastService.show({ content: 'kept too', delay: 5000, persistent: true })
toastService.show({ content: 'popup only', delay: 5000, persistent: false })
expect(toasts.map((t) => t.content)).toEqual(['kept too', 'kept'])
})
it('still pops up non-persistent toasts while popups are suppressed', () => {
const popups: Toast[] = []
toastService.suppressPopupToasts = true
toastService.showToast.subscribe((t) => popups.push(t))
toastService.show({ content: 'kept', delay: 5000 })
toastService.showInfo('popup only')
expect(popups.map((t) => t?.content)).toEqual(['popup only'])
})
it('removes toast on close', () => {
const toast = {
title: 'Title',
@@ -106,4 +153,46 @@ describe('ToastService', () => {
toastService.suppressPopupToasts = true
toastService.showInfo('Info toast')
})
it('sets the level for showSuccess, showInfo and showError', () => {
const shown: Toast[] = []
toastService.showToast.subscribe((t) => shown.push(t))
toastService.showSuccess('Saved')
toastService.showInfo('Started')
toastService.showError('Failed')
expect(shown.map((t) => t.level)).toEqual([
ToastLevel.Success,
undefined,
ToastLevel.Error,
])
})
it('adds silent toasts to the list without a popup', () => {
let toasts: Toast[] = []
const popups: Toast[] = []
toastService.getToasts().subscribe((t) => (toasts = [...t]))
toastService.showToast.subscribe((t) => popups.push(t))
toastService.show({ content: 'Recorded', delay: 5000, silent: true })
expect(toasts.map((t) => t.content)).toEqual(['Recorded'])
expect(popups).toHaveLength(0)
})
it('timestamps toasts and tracks read state', () => {
let toasts: Toast[] = []
toastService.getToasts().subscribe((t) => (toasts = [...t]))
toastService.showError('Failed')
expect(toasts[0].timestamp).toBeInstanceOf(Date)
expect(toasts[0].read).toBe(false)
toastService.markAllRead()
expect(toasts[0].read).toBe(true)
// arriving while the dropdown is open counts as seen
toastService.suppressPopupToasts = true
toastService.showError('Failed again')
expect(toasts[0].read).toBe(true)
})
})
+53 -8
View File
@@ -2,6 +2,12 @@ import { Injectable } from '@angular/core'
import { Subject } from 'rxjs'
import { v4 as uuidv4 } from 'uuid'
export enum ToastLevel {
Info = 'info',
Success = 'success',
Error = 'error',
}
export interface Toast {
id?: string
@@ -9,15 +15,32 @@ export interface Toast {
delay: number
delayRemaining?: number
action?: any
actionName?: string
classname?: string
/** Defaults to ToastLevel.Info */
level?: ToastLevel
error?: any
/**
* Whether the toast is kept in the notifications dropdown after its popup
* closes. Defaults to true.
*/
persistent?: boolean
/**
* Adds the toast to the notifications dropdown without a popup, e.g. when
* the same information is already on screen. Defaults to false.
*/
silent?: boolean
/** When the toast was shown, set automatically */
timestamp?: Date
/** Whether the toast has been seen in the notifications dropdown */
read?: boolean
}
@Injectable({
@@ -42,29 +65,51 @@ export class ToastService {
if (!toast.id) {
toast.id = uuidv4()
}
toast.timestamp ??= new Date()
if (typeof toast.error === 'string') {
try {
toast.error = JSON.parse(toast.error)
} catch (e) {}
}
this.toasts.unshift(toast)
if (!this._suppressPopupToasts) {
const persistent = toast.persistent !== false
if (persistent) {
// the dropdown is open, so it's seen as it arrives
toast.read = !!this._suppressPopupToasts
this.toasts.unshift(toast)
this.toastsSubject.next(this.toasts)
}
// Popups are suppressed while the dropdown is open
if (!toast.silent && (!this._suppressPopupToasts || !persistent)) {
this.showToast.next(toast)
}
this.toastsSubject.next(this.toasts)
}
showError(content: string, error: any = null, delay: number = 10000) {
this.show({
content: content,
delay: delay,
classname: 'error',
level: ToastLevel.Error,
error,
})
}
showInfo(content: string, delay: number = 5000) {
this.show({ content: content, delay: delay })
this.show({ content: content, delay: delay, persistent: false })
}
/** For confirming that something the user did has completed */
showSuccess(content: string, delay: number = 5000) {
this.show({
content: content,
delay: delay,
level: ToastLevel.Success,
persistent: false,
})
}
markAllRead() {
this.toasts.forEach((toast) => (toast.read = true))
this.toastsSubject.next(this.toasts)
}
closeToast(toast: Toast) {
@@ -5,11 +5,15 @@ import {
HttpTestingController,
} from '@angular/common/http/testing'
import { environment } from 'src/environments/environment'
import { DocumentListViewService } from './document-list-view.service'
import { Toast, ToastService } from './toast.service'
import { TrashService } from './trash.service'
describe('TrashService', () => {
let service: TrashService
let httpTestingController: HttpTestingController
let toastService: ToastService
let documentListViewService: DocumentListViewService
beforeEach(() => {
TestBed.configureTestingModule({
@@ -17,6 +21,8 @@ describe('TrashService', () => {
})
service = TestBed.inject(TrashService)
httpTestingController = TestBed.inject(HttpTestingController)
toastService = TestBed.inject(ToastService)
documentListViewService = TestBed.inject(DocumentListViewService)
})
it('should call correct endpoint for getTrash', () => {
@@ -56,4 +62,47 @@ describe('TrashService', () => {
documents: [1, 2, 3],
})
})
it('should show a persistent moved to trash toast with undo', () => {
let toasts: Toast[] = []
toastService.getToasts().subscribe((t) => (toasts = [...t]))
const reloadSpy = jest
.spyOn(documentListViewService, 'reload')
.mockImplementation(() => {})
service.notifyMovedToTrash('Moved 2 document(s) to the trash', [1, 2])
expect(toasts).toHaveLength(1)
expect(toasts[0].actionName).toEqual('Undo')
toasts[0].action()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}trash/`
)
expect(req.request.body).toEqual({ action: 'restore', documents: [1, 2] })
req.flush('OK')
expect(reloadSpy).toHaveBeenCalled()
expect(toasts[0].content).toEqual('Document(s) restored')
})
it('should show an error if undo fails', () => {
let toast: Toast
toastService.getToasts().subscribe((t) => (toast = t[0]))
const errorSpy = jest.spyOn(toastService, 'showError')
service.notifyMovedToTrash('Moved "Invoice" to the trash', [1])
toast.action()
httpTestingController
.expectOne(`${environment.apiBaseUrl}trash/`)
.flush('error', { status: 500, statusText: 'Server Error' })
expect(errorSpy).toHaveBeenCalled()
})
it('should not offer undo without document ids', () => {
let toast: Toast
toastService.getToasts().subscribe((t) => (toast = t[0]))
service.notifyMovedToTrash('Moved 500 document(s) to the trash')
expect(toast.content).toEqual('Moved 500 document(s) to the trash')
expect(toast.action).toBeUndefined()
})
})
Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff. Show more