Compare commits

..
Author SHA1 Message Date
Nathanaël Houn a5c8b82da5 Documentation: add scratch_dir setting (#14498) 2026-10-19 15:00:28 -07:00
146 changed files with 2439 additions and 5293 deletions

No files matched your search

+16
View File
@@ -39,6 +39,7 @@ ARG RUNTIME_PACKAGES="\
ghostscript \
gnupg \
icc-profiles-free \
imagemagick \
# PostgreSQL
postgresql-client \
# MySQL / MariaDB
@@ -92,6 +93,21 @@ RUN set -eux \
https://github.com/paperless-ngx/builder/releases/download/jbig2enc-trixie-v${JBIG2ENC_VERSION}/jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \
&& dpkg --install ./jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb
# setup docker-specific things
# These change sometimes, but rarely
WORKDIR /usr/src/paperless/src/docker/
COPY [ \
"docker/rootfs/etc/ImageMagick-6/paperless-policy.xml", \
"./" \
]
RUN set -eux \
&& echo "Configuring ImageMagick" \
&& mkdir -p /etc/ImageMagick-6 \
&& mv paperless-policy.xml /etc/ImageMagick-6/policy.xml
# Packages needed only for building a few quick Python
# dependencies
ARG BUILD_PACKAGES="\
+4 -1
View File
@@ -111,7 +111,10 @@ jobs:
timeout-minutes: 12
uses: $/.github/actions/apt-install
with:
packages: unpaper tesseract-ocr ghostscript poppler-utils qpdf
packages: unpaper tesseract-ocr imagemagick ghostscript poppler-utils
- name: Configure ImageMagick
run: |
sudo cp docker/rootfs/etc/ImageMagick-6/paperless-policy.xml /etc/ImageMagick-7/policy.xml
- name: Install Python dependencies
env:
PYTHON_VERSION: ${{ steps.setup-python.outputs.python-version }}
+3
View File
@@ -129,6 +129,7 @@ ARG RUNTIME_PACKAGES="\
ghostscript \
gnupg \
icc-profiles-free \
imagemagick \
# PostgreSQL
postgresql-client \
# MySQL / MariaDB
@@ -166,6 +167,8 @@ RUN set -eux \
https://github.com/paperless-ngx/builder/releases/download/jbig2enc-trixie-v${JBIG2ENC_VERSION}/jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \
&& echo "Installing jbig2enc" \
&& dpkg --install ./jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \
&& echo "Configuring imagemagick" \
&& cp /etc/ImageMagick-6/paperless-policy.xml /etc/ImageMagick-6/policy.xml \
&& echo "Cleaning up image layer" \
&& rm --force --verbose *.deb \
&& rm --recursive --force --verbose /var/lib/apt/lists/* \
@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policymap [
<!ELEMENT policymap (policy)+>
<!ATTLIST policymap xmlns CDATA #FIXED ''>
<!ELEMENT policy EMPTY>
<!ATTLIST policy xmlns CDATA #FIXED '' domain NMTOKEN #REQUIRED
name NMTOKEN #IMPLIED pattern CDATA #IMPLIED rights NMTOKEN #IMPLIED
stealth NMTOKEN #IMPLIED value CDATA #IMPLIED>
]>
<!--
Configure ImageMagick policies.
Domains include system, delegate, coder, filter, path, or resource.
Rights include none, read, write, execute and all. Use | to combine them,
for example: "read | write" to permit read from, or write to, a path.
Use a glob expression as a pattern.
Suppose we do not want users to process MPEG video images:
<policy domain="delegate" rights="none" pattern="mpeg:decode" />
Here we do not want users reading images from HTTP:
<policy domain="coder" rights="none" pattern="HTTP" />
The /repository file system is restricted to read only. We use a glob
expression to match all paths that start with /repository:
<policy domain="path" rights="read" pattern="/repository/*" />
Lets prevent users from executing any image filters:
<policy domain="filter" rights="none" pattern="*" />
Any large image is cached to disk rather than memory:
<policy domain="resource" name="area" value="1GP"/>
Define arguments for the memory, map, area, width, height and disk resources
with SI prefixes (.e.g 100MB). In addition, resource policies are maximums
for each instance of ImageMagick (e.g. policy memory limit 1GB, -limit 2GB
exceeds policy maximum so memory limit is 1GB).
Rules are processed in order. Here we want to restrict ImageMagick to only
read or write a small subset of proven web-safe image types:
<policy domain="delegate" rights="none" pattern="*" />
<policy domain="filter" rights="none" pattern="*" />
<policy domain="coder" rights="none" pattern="*" />
<policy domain="coder" rights="read|write" pattern="{GIF,JPEG,PNG,WEBP}" />
-->
<policymap>
<!-- <policy domain="system" name="shred" value="2"/> -->
<!-- <policy domain="system" name="precision" value="6"/> -->
<!-- <policy domain="system" name="memory-map" value="anonymous"/> -->
<!-- <policy domain="system" name="max-memory-request" value="256MiB"/> -->
<!-- <policy domain="resource" name="temporary-path" value="/tmp"/> -->
<policy domain="resource" name="memory" value="256MiB"/>
<policy domain="resource" name="map" value="512MiB"/>
<policy domain="resource" name="width" value="16KP"/>
<policy domain="resource" name="height" value="16KP"/>
<!-- <policy domain="resource" name="list-length" value="128"/> -->
<policy domain="resource" name="area" value="128MB"/>
<policy domain="resource" name="disk" value="1GiB"/>
<!-- <policy domain="resource" name="file" value="768"/> -->
<!-- <policy domain="resource" name="thread" value="4"/> -->
<!-- <policy domain="resource" name="throttle" value="0"/> -->
<!-- <policy domain="resource" name="time" value="3600"/> -->
<!-- Paperless does not process SVG or ImageMagick scripting formats. -->
<policy domain="coder" rights="none" pattern="SVG" />
<policy domain="coder" rights="none" pattern="SVGZ" />
<policy domain="coder" rights="none" pattern="MSVG" />
<policy domain="coder" rights="none" pattern="RSVG" />
<policy domain="coder" rights="none" pattern="MSL" />
<policy domain="coder" rights="none" pattern="MVG" />
<policy domain="coder" rights="none" pattern="EPHEMERAL" />
<!-- <policy domain="module" rights="none" pattern="{PS,PDF,XPS}" /> -->
<!-- <policy domain="delegate" rights="none" pattern="HTTPS" /> -->
<!-- <policy domain="path" rights="none" pattern="@*" /> -->
<!-- <policy domain="cache" name="memory-map" value="anonymous"/> -->
<!-- <policy domain="cache" name="synchronize" value="True"/> -->
<!-- <policy domain="cache" name="shared-secret" value="passphrase" stealth="true"/> -->
<!-- <policy domain="system" name="pixel-cache-memory" value="anonymous"/> -->
<!-- <policy domain="system" name="shred" value="2"/> -->
<!-- <policy domain="system" name="precision" value="6"/> -->
<!-- use curl -->
<policy domain="delegate" rights="none" pattern="URL" />
<policy domain="delegate" rights="none" pattern="HTTPS" />
<policy domain="delegate" rights="none" pattern="HTTP" />
<!-- in order to avoid to get image with password text -->
<policy domain="path" rights="none" pattern="@*"/>
<!-- disable ghostscript format types -->
<policy domain="coder" rights="none" pattern="PS" />
<policy domain="coder" rights="none" pattern="PS2" />
<policy domain="coder" rights="none" pattern="PS3" />
<policy domain="coder" rights="none" pattern="EPS" />
<policy domain="coder" rights="read|write" pattern="PDF" />
<policy domain="coder" rights="none" pattern="XPS" />
</policymap>
-11
View File
@@ -162,17 +162,6 @@ including `gt` (>), `gte` (>=), `lt` (<), `lte` (<=), and `range`.
Lastly, document link fields support a `contains` operator that behaves
like a "is superset of" check.
Date fields also accept relative expressions supported by
[dateparser](https://dateparser.readthedocs.io/en/stable/), such as `today`,
`in 30 days`, or `1 month ago`. For example, dates within the next 30 days:
```json
["expiry", "range", ["today", "in 30 days"]]
```
Expressions resolve each time the query runs, using `PAPERLESS_TIME_ZONE` and
`PAPERLESS_DATE_PARSER_LANGUAGES` (automatic language detection when unset).
### `/api/search/autocomplete/`
Get auto completions for a partial search term.
+29 -14
View File
@@ -370,6 +370,15 @@ instead of being removed from the filesystem. Only the original version is kept.
This setting was previously named PAPERLESS_TRASH_DIR.
#### [`PAPERLESS_SCRATCH_DIR=<path>`](#PAPERLESS_SCRATCH_DIR) {#PAPERLESS_SCRATCH_DIR}
: This folder is used as a temporary workspace for multiple paperless operations, including
consuming documents, editing, and the document exporter.
This must be writeable by the user running paperless.
Defaults to "/tmp/paperless".
#### [`PAPERLESS_MEDIA_ROOT=<path>`](#PAPERLESS_MEDIA_ROOT) {#PAPERLESS_MEDIA_ROOT}
: This is where your documents and thumbnails are stored.
@@ -1315,17 +1324,29 @@ valid crontab(5) expression describing when to run.
#### [`PAPERLESS_CONVERT_MEMORY_LIMIT=<num>`](#PAPERLESS_CONVERT_MEMORY_LIMIT) {#PAPERLESS_CONVERT_MEMORY_LIMIT}
!!! warning
: 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.
Deprecated and has no effect, since PDF thumbnails no longer use
ImageMagick. It will be removed in a future release.
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.
#### [`PAPERLESS_CONVERT_TMPDIR=<path>`](#PAPERLESS_CONVERT_TMPDIR) {#PAPERLESS_CONVERT_TMPDIR}
!!! warning
: 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.
Deprecated and has no effect, since PDF thumbnails no longer use
ImageMagick. It will be removed in a future release.
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.
#### [`PAPERLESS_APPS=<string>`](#PAPERLESS_APPS) {#PAPERLESS_APPS}
@@ -1873,17 +1894,11 @@ the literal path for that program.
#### [`PAPERLESS_CONVERT_BINARY=<path>`](#PAPERLESS_CONVERT_BINARY) {#PAPERLESS_CONVERT_BINARY}
!!! warning
Deprecated and has no effect, since Paperless-ngx no longer uses
ImageMagick. It will be removed in a future release.
: Defaults to "convert".
#### [`PAPERLESS_GS_BINARY=<path>`](#PAPERLESS_GS_BINARY) {#PAPERLESS_GS_BINARY}
!!! warning
Deprecated and has no effect, since nothing in Paperless-ngx calls
Ghostscript by this path. It will be removed in a future release.
: Defaults to "gs".
## Docker-specific options {#docker}
+13 -2
View File
@@ -177,16 +177,17 @@ 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
- `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 thumbnail generation and barcode detection
- `poppler-utils` for barcode detection
Use this list for your preferred package management:
```
python3 python3-pip python3-dev fonts-liberation gnupg libpq-dev default-libmysqlclient-dev pkg-config libmagic-dev poppler-utils
python3 python3-pip python3-dev imagemagick fonts-liberation gnupg libpq-dev default-libmysqlclient-dev pkg-config libmagic-dev poppler-utils
```
These dependencies are required for OCRmyPDF, which is used for text
@@ -415,6 +416,16 @@ 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.
Configure the active ImageMagick policy file (commonly
`/etc/ImageMagick-6/policy.xml` or `/etc/ImageMagick-7/policy.xml`) and
adjust similar to [the docker policy file](https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/refs/heads/main/docker/rootfs/etc/ImageMagick-6/paperless-policy.xml). You should also include restrictions as noted there.
**Optional: Install the [jbig2enc](https://ocrmypdf.readthedocs.io/en/latest/jbig2.html) encoder.**
This will reduce the size of generated PDF documents. You'll most likely need to compile this yourself, because this
software has been patented until around 2017 and binary packages are not available for most distributions.
+8
View File
@@ -16,6 +16,7 @@
#PAPERLESS_CONSUMPTION_DIR=../consume
#PAPERLESS_DATA_DIR=../data
#PAPERLESS_EMPTY_TRASH_DIR=
#PAPERLESS_SCRATCH_DIR=/tmp/paperless
#PAPERLESS_MEDIA_ROOT=../media
#PAPERLESS_STATICDIR=../static
#PAPERLESS_FILENAME_FORMAT=
@@ -50,6 +51,8 @@ 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
@@ -85,3 +88,8 @@ PAPERLESS_SECRET_KEY=change-me
#PAPERLESS_TIKA_ENABLED=false
#PAPERLESS_TIKA_ENDPOINT=http://localhost:9998
#PAPERLESS_TIKA_GOTENBERG_ENDPOINT=http://localhost:3000
# Binaries
#PAPERLESS_CONVERT_BINARY=/usr/bin/convert
#PAPERLESS_GS_BINARY=/usr/bin/gs
+1 -1
View File
@@ -28,7 +28,7 @@ module.exports = {
'abstract-paperless-service',
],
transformIgnorePatterns: [
'node_modules/(?!.*(\\.mjs$|tslib|lodash-es|normalize-diacritics|marked|@angular/common/locales/.*\\.js$))',
'node_modules/(?!.*(\\.mjs$|tslib|lodash-es|normalize-diacritics|@angular/common/locales/.*\\.js$))',
],
moduleNameMapper: {
...esmPreset.moduleNameMapper,
+596 -700
View File
File diff suppressed because it is too large. Load diff
+20 -21
View File
@@ -15,22 +15,21 @@
},
"private": true,
"dependencies": {
"@angular/cdk": "^22.2.2",
"@angular/common": "~22.2.2",
"@angular/compiler": "~22.2.2",
"@angular/core": "~22.2.2",
"@angular/forms": "~22.2.2",
"@angular/localize": "~22.2.2",
"@angular/platform-browser": "~22.2.2",
"@angular/router": "~22.2.2",
"@angular/cdk": "^22.2.0",
"@angular/common": "~22.2.0",
"@angular/compiler": "~22.2.0",
"@angular/core": "~22.2.0",
"@angular/forms": "~22.2.0",
"@angular/localize": "~22.2.0",
"@angular/platform-browser": "~22.2.0",
"@angular/router": "~22.2.0",
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
"@ng-select/ng-select": "~24.2.1",
"@ng-select/ng-select": "~24.1.2",
"@ngneat/dirty-check-forms": "^3.0.3",
"@popperjs/core": "^2.11.8",
"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",
@@ -46,16 +45,16 @@
},
"devDependencies": {
"@angular-builders/jest": "^22.0.1",
"@angular-devkit/core": "^22.2.2",
"@angular-devkit/schematics": "^22.2.2",
"@angular-eslint/builder": "22.6.0",
"@angular-eslint/eslint-plugin": "22.6.0",
"@angular-eslint/eslint-plugin-template": "22.6.0",
"@angular-eslint/schematics": "22.6.0",
"@angular-eslint/template-parser": "22.6.0",
"@angular/build": "22.2.2",
"@angular/cli": "22.2.2",
"@angular/compiler-cli": "~22.2.2",
"@angular-devkit/core": "^22.2.0",
"@angular-devkit/schematics": "^22.2.0",
"@angular-eslint/builder": "22.5.0",
"@angular-eslint/eslint-plugin": "22.5.0",
"@angular-eslint/eslint-plugin-template": "22.5.0",
"@angular-eslint/schematics": "22.5.0",
"@angular-eslint/template-parser": "22.5.0",
"@angular/build": "22.2.0",
"@angular/cli": "22.2.0",
"@angular/compiler-cli": "~22.2.0",
"@playwright/test": "^1.62.1",
"@types/jest": "^30.0.0",
"@types/node": "^26.6.2",
@@ -66,7 +65,7 @@
"jest": "30.5.2",
"jest-environment-jsdom": "^30.5.2",
"jest-junit": "^17.0.0",
"jest-preset-angular": "^17.0.3",
"jest-preset-angular": "^17.0.0",
"jest-websocket-mock": "^2.5.0",
"prettier": "^3.9.9",
"prettier-plugin-organize-imports": "^4.3.0",
+333 -301
View File
File diff suppressed because it is too large. Load diff
+9 -15
View File
@@ -19,7 +19,6 @@ import { PermissionsGuard } from './guards/permissions.guard'
import { HotKeyService } from './services/hot-key.service'
import { PermissionsService } from './services/permissions.service'
import { SettingsService } from './services/settings.service'
import { TasksService } from './services/tasks.service'
import { Toast, ToastService } from './services/toast.service'
import {
FileStatus,
@@ -35,7 +34,6 @@ describe('AppComponent', () => {
let toastService: ToastService
let router: Router
let settingsService: SettingsService
let tasksService: TasksService
let hotKeyService: HotKeyService
beforeEach(async () => {
@@ -62,7 +60,6 @@ describe('AppComponent', () => {
websocketStatusService = TestBed.inject(WebsocketStatusService)
permissionsService = TestBed.inject(PermissionsService)
settingsService = TestBed.inject(SettingsService)
tasksService = TestBed.inject(TasksService)
toastService = TestBed.inject(ToastService)
router = TestBed.inject(Router)
hotKeyService = TestBed.inject(HotKeyService)
@@ -120,33 +117,30 @@ describe('AppComponent', () => {
expect(toast.action).toBeUndefined()
})
it('should only reload tasks when a document is detected', () => {
it('should display toast on document added', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
const toastSpy = jest.spyOn(toastService, 'show')
const reloadSpy = jest.spyOn(tasksService, 'reload')
const fileStatusSubject = new Subject<FileStatus>()
jest
.spyOn(websocketStatusService, 'onDocumentDetected')
.mockReturnValue(fileStatusSubject)
component.ngOnInit()
fileStatusSubject.next(new FileStatus())
expect(reloadSpy).toHaveBeenCalled()
expect(toastSpy).not.toHaveBeenCalled()
expect(toastSpy).toHaveBeenCalled()
})
it('should add consumed documents to the bell without a popup', () => {
it('should suppress dashboard notifications if set', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
const popupSpy = jest.fn()
toastService.showToast.subscribe(popupSpy)
let toasts: Toast[] = []
toastService.getToasts().subscribe((t) => (toasts = [...t]))
jest.spyOn(settingsService, 'get').mockReturnValue(true)
jest.spyOn(router, 'url', 'get').mockReturnValue('/dashboard')
const toastSpy = jest.spyOn(toastService, 'show')
const fileStatusSubject = new Subject<FileStatus>()
jest
.spyOn(websocketStatusService, 'onDocumentConsumptionFinished')
.spyOn(websocketStatusService, 'onDocumentDetected')
.mockReturnValue(fileStatusSubject)
component.ngOnInit()
fileStatusSubject.next(new FileStatus())
expect(toasts).toHaveLength(1)
expect(popupSpy).not.toHaveBeenCalled()
expect(toastSpy).not.toHaveBeenCalled()
})
it('should display toast on document failed', () => {
+50 -19
View File
@@ -14,7 +14,7 @@ import {
} from './services/permissions.service'
import { SettingsService } from './services/settings.service'
import { TasksService } from './services/tasks.service'
import { ToastLevel, ToastService } from './services/toast.service'
import { ToastService } from './services/toast.service'
import { WebsocketStatusService } from './services/websocket-status.service'
@Component({
@@ -61,6 +61,18 @@ export class AppComponent implements OnInit, OnDestroy {
}
}
private showNotification(key) {
if (
this.router.url == '/dashboard' &&
this.settings.get(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD
)
) {
return false
}
return this.settings.get(key)
}
ngOnInit(): void {
this.websocketStatusService.connect()
@@ -68,22 +80,29 @@ export class AppComponent implements OnInit, OnDestroy {
.onDocumentConsumptionFinished()
.subscribe((status) => {
this.tasksService.reload()
if (this.settings.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS)) {
const canView = this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.Document
)
// the upload status tray already shows this, so only keep a record
this.toastService.show({
content: $localize`Document ${status.filename} was added to Paperless-ngx.`,
delay: 10000,
level: ToastLevel.Success,
silent: true,
actionName: canView ? $localize`Open document` : undefined,
action: canView
? () => this.router.navigate(['documents', status.documentId])
: undefined,
})
if (
this.showNotification(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS)
) {
if (
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.Document
)
) {
this.toastService.show({
content: $localize`Document ${status.filename} was added to Paperless-ngx.`,
delay: 10000,
actionName: $localize`Open document`,
action: () => {
this.router.navigate(['documents', status.documentId])
},
})
} else {
this.toastService.show({
content: $localize`Document ${status.filename} was added to Paperless-ngx.`,
delay: 10000,
})
}
}
})
@@ -91,7 +110,9 @@ export class AppComponent implements OnInit, OnDestroy {
.onDocumentConsumptionFailed()
.subscribe((status) => {
this.tasksService.reload()
if (this.settings.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED)) {
if (
this.showNotification(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED)
) {
this.toastService.showError(
$localize`Could not add ${status.filename}\: ${status.message}`
)
@@ -100,8 +121,18 @@ export class AppComponent implements OnInit, OnDestroy {
this.newDocumentSubscription = this.websocketStatusService
.onDocumentDetected()
.subscribe(() => {
.subscribe((status) => {
this.tasksService.reload()
if (
this.showNotification(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT
)
) {
this.toastService.show({
content: $localize`Document ${status.filename} is being processed by Paperless-ngx.`,
delay: 5000,
})
}
})
this.hotKeyService
@@ -190,7 +190,7 @@ export class ConfigComponent
this.initialize(config)
this.store.next(config)
this.settingsService.initializeSettings().subscribe()
this.toastService.showSuccess($localize`Configuration updated`)
this.toastService.showInfo($localize`Configuration updated`)
},
error: (e) => {
this.loading.set(false)
@@ -217,7 +217,7 @@ export class ConfigComponent
this.initialize(config)
this.store.next(config)
this.settingsService.initializeSettings().subscribe()
this.toastService.showSuccess($localize`File successfully updated`)
this.toastService.showInfo($localize`File successfully updated`)
},
error: (e) => {
this.loading.set(false)
@@ -425,8 +425,10 @@
<div class="row">
<div class="col">
<pngx-input-check i18n-title title="Add notifications when document processing completes successfully" formControlName="notificationsConsumerSuccess" i18n-hint hint="These are added to the notifications menu without a popup, processing progress is shown at the bottom of the screen."></pngx-input-check>
<pngx-input-check i18n-title title="Show notifications when new documents are detected" formControlName="notificationsConsumerNewDocument"></pngx-input-check>
<pngx-input-check i18n-title title="Show notifications when document processing completes successfully" formControlName="notificationsConsumerSuccess"></pngx-input-check>
<pngx-input-check i18n-title title="Show notifications when document processing fails" formControlName="notificationsConsumerFailed"></pngx-input-check>
<pngx-input-check i18n-title title="Suppress notifications on dashboard" formControlName="notificationsConsumerSuppressOnDashboard" i18n-hint hint="This will suppress all messages about document processing status on the dashboard."></pngx-input-check>
</div>
</div>
@@ -307,7 +307,7 @@ describe('SettingsComponent', () => {
expect(toastErrorSpy).toHaveBeenCalled()
expect(storeSpy).toHaveBeenCalled()
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
expect(setSpy).toHaveBeenCalledTimes(33)
expect(setSpy).toHaveBeenCalledTimes(35)
expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
HideableSidebarItemID.Workflows,
])
@@ -63,7 +63,7 @@ import {
SettingsService,
} from 'src/app/services/settings.service'
import { SystemStatusService } from 'src/app/services/system-status.service'
import { Toast, ToastLevel, ToastService } from 'src/app/services/toast.service'
import { Toast, ToastService } from 'src/app/services/toast.service'
import { locationReload } from 'src/app/utils/navigation'
import { CheckComponent } from '../../common/input/check/check.component'
import { ColorComponent } from '../../common/input/color/color.component'
@@ -190,8 +190,10 @@ export class SettingsComponent
searchDbOnly: new FormControl(null),
searchLink: new FormControl(null),
notificationsConsumerNewDocument: new FormControl(null),
notificationsConsumerSuccess: new FormControl(null),
notificationsConsumerFailed: new FormControl(null),
notificationsConsumerSuppressOnDashboard: new FormControl(null),
savedViewsWarnOnUnsavedChange: new FormControl(null),
sidebarViewsShowCount: new FormControl(null),
@@ -369,12 +371,18 @@ export class SettingsComponent
updateCheckingEnabled: this.settings.get(
SETTINGS_KEYS.UPDATE_CHECKING_ENABLED
),
notificationsConsumerNewDocument: this.settings.get(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT
),
notificationsConsumerSuccess: this.settings.get(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS
),
notificationsConsumerFailed: this.settings.get(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED
),
notificationsConsumerSuppressOnDashboard: this.settings.get(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD
),
savedViewsWarnOnUnsavedChange: this.settings.get(
SETTINGS_KEYS.SAVED_VIEWS_WARN_ON_UNSAVED_CHANGE
),
@@ -563,6 +571,10 @@ export class SettingsComponent
SETTINGS_KEYS.DATE_FORMAT,
this.settingsForm.value.dateFormat
)
this.settings.set(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT,
this.settingsForm.value.notificationsConsumerNewDocument
)
this.settings.set(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS,
this.settingsForm.value.notificationsConsumerSuccess
@@ -571,6 +583,10 @@ export class SettingsComponent
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED,
this.settingsForm.value.notificationsConsumerFailed
)
this.settings.set(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD,
this.settingsForm.value.notificationsConsumerSuppressOnDashboard
)
this.settings.set(
SETTINGS_KEYS.NOTES_ENABLED,
this.settingsForm.value.notesEnabled
@@ -648,11 +664,8 @@ export class SettingsComponent
let savedToast: Toast = {
content: $localize`Settings were saved successfully.`,
delay: 5000,
level: ToastLevel.Success,
persistent: false,
}
if (reloadRequired) {
savedToast.persistent = true
savedToast.content = $localize`Settings were saved successfully. Reload is required to apply some changes.`
savedToast.actionName = $localize`Reload now`
savedToast.action = () => {
@@ -99,16 +99,11 @@ describe('TrashComponent', () => {
modal.componentInstance.confirmClicked.next()
expect(toastErrorSpy).toHaveBeenCalled()
let toasts
toastService.getToasts().subscribe((allToasts) => {
toasts = [...allToasts]
})
trashSpy.mockReturnValue(of('OK'))
component.delete(documentsInTrash[0])
expect(modal).toBeDefined()
modal.componentInstance.confirmClicked.next()
expect(trashSpy).toHaveBeenCalled()
expect(toasts[0].content).toContain('deleted')
})
it('should support empty trash, show error if needed', () => {
@@ -11,7 +11,7 @@ import { takeUntil, tap } from 'rxjs'
import { Document } from 'src/app/data/document'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastLevel, ToastService } from 'src/app/services/toast.service'
import { ToastService } from 'src/app/services/toast.service'
import { TrashService } from 'src/app/services/trash.service'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
@@ -87,11 +87,9 @@ export class TrashComponent
modal.componentInstance.buttonsEnabled.set(false)
this.trashService.emptyTrash([document.id]).subscribe({
next: () => {
this.toastService.show({
content: $localize`Document "${document.title}" deleted`,
delay: 5000,
level: ToastLevel.Success,
})
this.toastService.showInfo(
$localize`Document "${document.title}" deleted`
)
modal.close()
this.reload()
},
@@ -124,11 +122,7 @@ export class TrashComponent
.emptyTrash(documents ? Array.from(documents) : null)
.subscribe({
next: () => {
this.toastService.show({
content: $localize`Document(s) deleted`,
delay: 5000,
level: ToastLevel.Success,
})
this.toastService.showInfo($localize`Document(s) deleted`)
this.allToggled.set(false)
modal.close()
this.reload()
@@ -150,7 +144,6 @@ export class TrashComponent
this.toastService.show({
content: $localize`Document "${document.title}" restored`,
delay: 5000,
level: ToastLevel.Success,
actionName: $localize`Open document`,
action: () => {
this.router.navigate(['documents', document.id])
@@ -172,11 +165,7 @@ export class TrashComponent
.restoreDocuments(documents ? Array.from(documents) : null)
.subscribe({
next: () => {
this.toastService.show({
content: $localize`Document(s) restored`,
delay: 5000,
level: ToastLevel.Success,
})
this.toastService.showInfo($localize`Document(s) restored`)
this.allToggled.set(false)
this.reload()
},
@@ -101,7 +101,7 @@ describe('UsersAndGroupsComponent', () => {
component.editUser(users[0])
const editDialog = modal.componentInstance as UserEditDialogComponent
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
editDialog.failed.emit()
expect(toastErrorSpy).toHaveBeenCalled()
settingsService.currentUser.set(users[1]) // simulate logged in as different user
@@ -120,7 +120,7 @@ describe('UsersAndGroupsComponent', () => {
const deleteDialog = modal.componentInstance as ConfirmDialogComponent
const deleteSpy = jest.spyOn(userService, 'delete')
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const listAllSpy = jest.spyOn(userService, 'listAll')
deleteSpy.mockReturnValueOnce(
throwError(() => new Error('error deleting user'))
@@ -186,7 +186,7 @@ describe('UsersAndGroupsComponent', () => {
component.editGroup(groups[0])
const editDialog = modal.componentInstance as GroupEditDialogComponent
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
editDialog.failed.emit()
expect(toastErrorSpy).toHaveBeenCalled()
editDialog.succeeded.emit(groups[0])
@@ -204,7 +204,7 @@ describe('UsersAndGroupsComponent', () => {
const deleteDialog = modal.componentInstance as ConfirmDialogComponent
const deleteSpy = jest.spyOn(groupService, 'delete')
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const listAllSpy = jest.spyOn(groupService, 'listAll')
deleteSpy.mockReturnValueOnce(
throwError(() => new Error('error deleting group'))
@@ -122,7 +122,7 @@ export class UsersAndGroupsComponent
)
}, 2500)
} else {
this.toastService.showSuccess(
this.toastService.showInfo(
$localize`Saved user "${newUser.username}".`
)
this.usersService.listAll().subscribe((r) => {
@@ -151,9 +151,7 @@ export class UsersAndGroupsComponent
this.usersService.delete(user).subscribe({
next: () => {
modal.close()
this.toastService.showSuccess(
$localize`Deleted user "${user.username}"`
)
this.toastService.showInfo($localize`Deleted user "${user.username}"`)
this.usersService.listAll().subscribe((r) => {
this.users.set(r.results)
})
@@ -180,9 +178,7 @@ export class UsersAndGroupsComponent
modal.componentInstance.succeeded
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((newGroup) => {
this.toastService.showSuccess(
$localize`Saved group "${newGroup.name}".`
)
this.toastService.showInfo($localize`Saved group "${newGroup.name}".`)
this.groupsService.listAll().subscribe((r) => {
this.groups.set(r.results)
})
@@ -208,9 +204,7 @@ export class UsersAndGroupsComponent
this.groupsService.delete(group).subscribe({
next: () => {
modal.close()
this.toastService.showSuccess(
$localize`Deleted group "${group.name}"`
)
this.toastService.showInfo($localize`Deleted group "${group.name}"`)
this.groupsService.listAll().subscribe((r) => {
this.groups.set(r.results)
})
@@ -29,20 +29,8 @@
</div>
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 me-sm-auto order-3 order-sm-1"
[class.mobile-hidden]="mobileSearchHidden()">
<div class="col-12 header-search mx-auto d-flex align-items-stretch gap-3">
<pngx-global-search class="flex-grow-1"></pngx-global-search>
<div *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Document }" class="btn-group header-upload-group flex-shrink-0">
<button class="btn btn-sm header-upload border-0 d-flex align-items-center justify-content-center py-0 px-2" type="button"
(click)="fileUpload.click()" aria-label="Upload documents" i18n-aria-label>
<i-bs class="d-flex" width="1.1em" height="1.1em" name="file-earmark-arrow-up"></i-bs><span class="ms-2 d-sm-none d-md-inline" i18n>Upload</span>
</button>
@if (uploadsInProgress > 0) {
<button class="btn btn-sm header-upload border-0 d-flex align-items-center py-0 px-2" type="button"
(click)="showUploadStatus()" aria-label="Show document processing status" i18n-aria-label>
<span class="spinner-border spinner-border-sm" aria-hidden="true"></span><span class="ms-2 small">{{ uploadsInProgress }}</span>
</button>
}
</div>
<div class="col-12 header-search mx-auto">
<pngx-global-search></pngx-global-search>
</div>
</div>
<ul ngbNav class="order-sm-3">
@@ -82,9 +70,6 @@
</ul>
</nav>
<input type="file" class="visually-hidden" id="navbar-file-upload" (change)="onFileSelected($event)" multiple #fileUpload tabindex="-1" aria-hidden="true">
<label for="navbar-file-upload" class="visually-hidden" i18n>Upload Files</label>
<div class="container-fluid">
<div class="row">
<nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse"
@@ -434,6 +419,5 @@
[ngClass]="slimSidebarEnabled ? 'col-slim' : 'col-sidebar-expanded'">
<router-outlet></router-outlet>
</main>
<pngx-upload-status-tray></pngx-upload-status-tray>
</div>
</div>
@@ -538,36 +538,7 @@ main {
.header-search {
width: 100%;
max-width: 50rem;
}
.header-search pngx-global-search {
min-width: 0;
}
// light w shadow to stand out against search
.header-upload {
--bs-btn-bg: rgba(255, 255, 255, .15);
--bs-btn-hover-bg: rgba(255, 255, 255, .22);
--bs-btn-active-bg: rgba(255, 255, 255, .3);
color: var(--pngx-primary-text-contrast) !important; // .navbar .dropdown > .btn in styles.scss
& + & {
border-left: 1px solid rgba(255, 255, 255, .2) !important;
}
&:focus {
box-shadow: none;
}
&:focus-visible {
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, .5);
}
}
.header-upload-group {
border-radius: var(--bs-border-radius-sm);
box-shadow: 0 1px 3px rgba(0, 0, 0, .35), 0 1px 1px rgba(0, 0, 0, .2);
max-width: 55rem;
}
@media screen and (max-width: 575.98px) {
@@ -659,7 +630,8 @@ main {
}
.dropdown-toggle::after {
display: none;
margin-left: 0.4em;
vertical-align: 0.155em;
}
.navbar .dropdown-menu {
@@ -696,7 +668,3 @@ main {
::ng-deep .navItemDrag .position-absolute i-bs {
display: none;
}
.smaller {
font-size: 0.75rem;
}
@@ -6,7 +6,7 @@ import {
} from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { BrowserModule, By } from '@angular/platform-browser'
import { BrowserModule } from '@angular/platform-browser'
import { ActivatedRoute, Router } from '@angular/router'
import { RouterTestingModule } from '@angular/router/testing'
import { NgbModal, NgbModalModule, NgbModule } from '@ng-bootstrap/ng-bootstrap'
@@ -33,11 +33,8 @@ import { SearchService } from 'src/app/services/rest/search.service'
import { SettingsService } from 'src/app/services/settings.service'
import { TasksService } from 'src/app/services/tasks.service'
import { ToastService } from 'src/app/services/toast.service'
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
import { environment } from 'src/environments/environment'
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
import { UploadStatusTrayComponent } from '../common/upload-status-tray/upload-status-tray.component'
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
import { AppFrameComponent } from './app-frame.component'
import { GlobalSearchComponent } from './global-search/global-search.component'
@@ -465,45 +462,6 @@ describe('AppFrameComponent', () => {
expect(routerSpy).toHaveBeenCalled()
})
it('should upload files selected from the navbar upload button', () => {
const uploadDocumentsService = TestBed.inject(UploadDocumentsService)
const uploadSpy = jest
.spyOn(uploadDocumentsService, 'uploadFiles')
.mockImplementation(() => {})
const toastSpy = jest.spyOn(toastService, 'showInfo')
const file = new File(['testing'], 'file.pdf')
const input = window.document.createElement('input')
jest.spyOn(input, 'files', 'get').mockReturnValue([file] as any)
component.onFileSelected({ target: input } as unknown as Event)
expect(toastSpy).not.toHaveBeenCalled()
expect(uploadSpy).toHaveBeenCalledWith([file])
})
it('should show in-progress uploads and expand the status tray', () => {
const websocketStatusService = TestBed.inject(WebsocketStatusService)
expect(component.uploadsInProgress).toEqual(0)
websocketStatusService.newFileUpload('file.pdf')
expect(component.uploadsInProgress).toEqual(1)
fixture.detectChanges()
const tray = fixture.debugElement.query(
By.directive(UploadStatusTrayComponent)
).componentInstance as UploadStatusTrayComponent
tray.toggleCollapsed()
expect(tray.collapsed()).toBe(true)
component.showUploadStatus()
expect(tray.collapsed()).toBe(false)
})
it('should not upload when no files are selected from the navbar', () => {
const uploadDocumentsService = TestBed.inject(UploadDocumentsService)
const uploadSpy = jest.spyOn(uploadDocumentsService, 'uploadFiles')
const input = window.document.createElement('input')
component.onFileSelected({ target: input } as unknown as Event)
expect(uploadSpy).not.toHaveBeenCalled()
})
it('should close all documents on logout', () => {
const closeAllSpy = jest.spyOn(openDocumentsService, 'closeAll')
component.onLogout()
@@ -525,7 +483,7 @@ describe('AppFrameComponent', () => {
it('should update saved view sorting on drag + drop, show info', () => {
const settingsSpy = jest.spyOn(settingsService, 'updateSidebarViewsSort')
const toastSpy = jest.spyOn(toastService, 'showSuccess')
const toastSpy = jest.spyOn(toastService, 'showInfo')
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
component.onDrop({ previousIndex: 0, currentIndex: 1 } as CdkDragDrop<
SavedView[]
@@ -567,7 +525,6 @@ describe('AppFrameComponent', () => {
it('should show toasts for django messages', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
jest.spyOn(messagesService, 'get').mockReturnValue([
{ level: DjangoMessageLevel.WARNING, message: 'Test warning' },
@@ -578,8 +535,7 @@ describe('AppFrameComponent', () => {
])
component.ngOnInit()
expect(toastErrorSpy).toHaveBeenCalledTimes(2)
expect(toastSuccessSpy).toHaveBeenCalledTimes(1)
expect(toastInfoSpy).toHaveBeenCalledTimes(2)
expect(toastInfoSpy).toHaveBeenCalledTimes(3)
})
it('should call maybeRefreshDocumentCounts after saved views reload', () => {
@@ -6,14 +6,7 @@ import {
moveItemInArray,
} from '@angular/cdk/drag-drop'
import { NgClass } from '@angular/common'
import {
Component,
HostListener,
inject,
OnInit,
signal,
viewChild,
} from '@angular/core'
import { Component, HostListener, inject, OnInit, signal } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
import {
@@ -55,15 +48,12 @@ import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SettingsService } from 'src/app/services/settings.service'
import { TasksService } from 'src/app/services/tasks.service'
import { ToastService } from 'src/app/services/toast.service'
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
import { environment } from 'src/environments/environment'
import { ChatComponent } from '../chat/chat/chat.component'
import { SwitchComponent } from '../common/input/switch/switch.component'
import { BrandMarkComponent } from '../common/logo/brand-mark/brand-mark.component'
import { LogoComponent } from '../common/logo/logo.component'
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
import { UploadStatusTrayComponent } from '../common/upload-status-tray/upload-status-tray.component'
import { SwitchComponent } from '../common/input/switch/switch.component'
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
import { GlobalSearchComponent } from './global-search/global-search.component'
@@ -94,7 +84,6 @@ const SCROLL_THRESHOLD = 16
TourNgBootstrap,
FormsModule,
SwitchComponent,
UploadStatusTrayComponent,
],
})
export class AppFrameComponent
@@ -112,10 +101,6 @@ export class AppFrameComponent
private modalService = inject(NgbModal)
permissionsService = inject(PermissionsService)
private djangoMessagesService = inject(DjangoMessagesService)
private uploadDocumentsService = inject(UploadDocumentsService)
private websocketStatusService = inject(WebsocketStatusService)
private readonly uploadStatusTray = viewChild(UploadStatusTrayComponent)
readonly appRemoteVersion = signal<AppRemoteVersion>(null)
readonly isMenuCollapsed = signal(true)
@@ -186,8 +171,6 @@ export class AppFrameComponent
this.toastService.showError(message.message)
break
case DjangoMessageLevel.SUCCESS:
this.toastService.showSuccess(message.message)
break
case DjangoMessageLevel.INFO:
case DjangoMessageLevel.DEBUG:
this.toastService.showInfo(message.message)
@@ -494,7 +477,7 @@ export class AppFrameComponent
this.settingsService.updateSidebarViewsSort(sidebarViews).subscribe({
next: () => {
this.toastService.showSuccess($localize`Sidebar views updated`)
this.toastService.showInfo($localize`Sidebar views updated`)
},
error: (e) => {
this.toastService.showError($localize`Error updating sidebar views`, e)
@@ -528,22 +511,6 @@ export class AppFrameComponent
}
}
onFileSelected(event: Event) {
const input = event.target as HTMLInputElement
const files = Array.from(input.files ?? [])
input.value = ''
if (!files.length) return
this.uploadDocumentsService.uploadFiles(files)
}
get uploadsInProgress(): number {
return this.websocketStatusService.getConsumerStatusNotCompleted().length
}
showUploadStatus() {
this.uploadStatusTray()?.expand()
}
onLogout() {
this.openDocumentsService.closeAll()
}
@@ -409,7 +409,7 @@ describe('GlobalSearchComponent', () => {
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
// fail first
editDialog.failed.emit({ error: 'error creating item' })
@@ -460,7 +460,7 @@ describe('GlobalSearchComponent', () => {
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
// fail first
editDialog.failed.emit({ error: 'error creating item' })
@@ -216,7 +216,7 @@ export class GlobalSearchComponent implements OnInit {
modalRef.componentInstance.dialogMode.set(EditDialogMode.EDIT)
modalRef.componentInstance.object = object
modalRef.componentInstance.succeeded.subscribe(() => {
this.toastService.showSuccess($localize`Successfully updated object.`)
this.toastService.showInfo($localize`Successfully updated object.`)
})
modalRef.componentInstance.failed.subscribe((e) => {
this.toastService.showError($localize`Error occurred saving object.`, e)
@@ -254,7 +254,7 @@ export class GlobalSearchComponent implements OnInit {
modalRef.componentInstance.dialogMode.set(EditDialogMode.EDIT)
modalRef.componentInstance.object = object
modalRef.componentInstance.succeeded.subscribe(() => {
this.toastService.showSuccess($localize`Successfully updated object.`)
this.toastService.showInfo($localize`Successfully updated object.`)
})
modalRef.componentInstance.failed.subscribe((e) => {
this.toastService.showError($localize`Error occurred saving object.`, e)
@@ -1,31 +1,28 @@
<li ngbDropdown class="nav-item position-relative" (openChange)="onOpenChange($event)">
@if (unread().length) {
<span class="notification-count badge rounded-pill z-3 pe-none position-absolute" [class.bg-danger]="hasUnreadError()" [class.bg-secondary]="!hasUnreadError()">{{ unread().length }}</span>
@if (toasts().length) {
<span class="notification-count badge rounded-pill z-3 pe-none bg-secondary position-absolute">{{ toasts().length }}</span>
}
<button class="btn navbar-action border-0" id="notificationsDropdown" ngbDropdownToggle aria-label="Notifications" i18n-aria-label>
<i-bs width="1.3em" height="1.3em" name="bell"></i-bs>
</button>
<div ngbDropdownMenu class="dropdown-menu-end shadow p-2" aria-labelledby="notificationsDropdown">
<div class="d-flex align-items-center px-1 mb-2">
<h6 class="mb-0" i18n>Notifications</h6>
@if (toasts().length) {
<button class="btn btn-link btn-sm p-0 ms-auto text-body-secondary text-decoration-none clear-all"
(click)="toastService.clearToasts()">
<i-bs width="1em" height="1em" name="check2-all" class="me-1"></i-bs><ng-container i18n>Clear All</ng-container>
</button>
}
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="notificationsDropdown">
<div class="btn-toolbar align-items-center" role="toolbar">
<h6 i18n>Notifications</h6>
<div class="btn-group ms-auto">
<button class="btn btn-sm btn-outline-secondary mb-2 ms-auto"
(click)="toastService.clearToasts()"
[disabled]="toasts().length === 0"
i18n>Clear All</button>
</div>
</div>
@if (toasts().length === 0) {
<div class="text-center text-body-secondary small py-4">
<i-bs width="1.5em" height="1.5em" name="bell" class="d-block mx-auto mb-2 opacity-50"></i-bs>
<ng-container i18n>No notifications</ng-container>
</div>
<p class="text-center mb-0 small text-muted"><em i18n>No notifications</em></p>
}
<div class="scroll-list d-flex flex-column gap-2">
<div class="scroll-list">
@for (toast of toasts(); track toast.id) {
<pngx-toast [autohide]="false" [toast]="toast" [now]="now()" [isNew]="newIds().has(toast.id)" (closed)="toastService.closeToast(toast)" (actioned)="onActioned(toast)"></pngx-toast>
<pngx-toast [autohide]="false" [toast]="toast" (hidden)="onHidden(toast)" (closed)="toastService.closeToast(toast)"></pngx-toast>
}
</div>
</div>
</div>
</li>
@@ -30,7 +30,3 @@
right: -3rem;
}
}
.clear-all:hover {
color: var(--bs-body-color) !important;
}
@@ -3,7 +3,7 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { Subject } from 'rxjs'
import { Toast, ToastLevel, ToastService } from 'src/app/services/toast.service'
import { Toast, ToastService } from 'src/app/services/toast.service'
import { ToastsDropdownComponent } from './toasts-dropdown.component'
const toasts = [
@@ -90,73 +90,4 @@ describe('ToastsDropdownComponent', () => {
component.onOpenChange(true)
expect(spy).toHaveBeenCalledWith(true)
})
it('should badge unread toasts, red when one is an error', () => {
toastsSubject.next([
{ id: 'a', content: 'Read', delay: 5000, read: true },
{ id: 'b', content: 'Unread', delay: 5000, read: false },
])
fixture.detectChanges()
let badge: HTMLElement = fixture.nativeElement.querySelector(
'.notification-count'
)
expect(badge.textContent.trim()).toEqual('1')
expect(badge.classList).toContain('bg-secondary')
toastsSubject.next([
{ id: 'c', content: 'Failed', delay: 5000, level: ToastLevel.Error },
])
fixture.detectChanges()
badge = fixture.nativeElement.querySelector('.notification-count')
expect(badge.classList).toContain('bg-danger')
toastsSubject.next([{ id: 'd', content: 'Seen', delay: 5000, read: true }])
fixture.detectChanges()
expect(
fixture.nativeElement.querySelector('.notification-count')
).toBeNull()
})
it('should mark all toasts read when opened', () => {
const markSpy = jest.spyOn(toastService, 'markAllRead')
component.onOpenChange(false)
expect(markSpy).not.toHaveBeenCalled()
component.onOpenChange(true)
expect(markSpy).toHaveBeenCalled()
})
it('should tick the clock only while open', () => {
jest.useFakeTimers()
component.onOpenChange(true)
const opened = component.now()
jest.advanceTimersByTime(30000)
const ticked = component.now()
expect(ticked).not.toBe(opened)
component.onOpenChange(false)
jest.advanceTimersByTime(60000)
expect(component.now()).toBe(ticked)
jest.useRealTimers()
})
it('should mark toasts that were unread when opened as new', () => {
toastsSubject.next([
{ id: 'a', content: 'Seen', delay: 5000, read: true },
{ id: 'b', content: 'Unseen', delay: 5000, read: false },
])
fixture.detectChanges()
component.onOpenChange(true)
expect([...component.newIds()]).toEqual(['b'])
component.onOpenChange(false)
expect(component.newIds().size).toEqual(0)
})
it('should clear the toast and close the menu when an action is used', () => {
const toast = { id: 'a', content: 'Moved', delay: 5000 }
const closeToastSpy = jest.spyOn(toastService, 'closeToast')
const closeSpy = jest.spyOn(component.dropdown, 'close')
component.onActioned(toast)
expect(closeToastSpy).toHaveBeenCalledWith(toast)
expect(closeSpy).toHaveBeenCalled()
})
})
@@ -1,20 +1,12 @@
import { Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import {
Component,
DestroyRef,
ViewChild,
computed,
inject,
signal,
} from '@angular/core'
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'
import {
NgbDropdown,
NgbDropdownModule,
NgbProgressbarModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Subscription, interval, map } from 'rxjs'
import { Toast, ToastLevel, ToastService } from 'src/app/services/toast.service'
import { map } from 'rxjs'
import { Toast, ToastService } from 'src/app/services/toast.service'
import { ToastComponent } from '../../common/toast/toast.component'
@Component({
@@ -30,44 +22,13 @@ import { ToastComponent } from '../../common/toast/toast.component'
})
export class ToastsDropdownComponent {
toastService = inject(ToastService)
private destroyRef = inject(DestroyRef)
private clock: Subscription
@ViewChild(NgbDropdown) dropdown: NgbDropdown
/** Drives the relative timestamps, ticks only while the dropdown is open */
readonly now = signal(new Date())
/** Notifications that were unread when the dropdown was opened */
readonly newIds = signal(new Set<string>())
readonly toasts = toSignal(
this.toastService.getToasts().pipe(map((toasts) => [...toasts])),
{ initialValue: [] as Toast[] }
)
readonly unread = computed(() => this.toasts().filter((t) => !t.read))
readonly hasUnreadError = computed(() =>
this.unread().some((t) => t.level === ToastLevel.Error)
)
onOpenChange(open: boolean): void {
this.toastService.suppressPopupToasts = open
this.clock?.unsubscribe()
this.newIds.set(new Set(open ? this.unread().map((t) => t.id) : []))
if (open) {
this.toastService.markAllRead()
this.now.set(new Date())
this.clock = interval(30000)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.now.set(new Date()))
}
}
/** Using an action (Undo, Open document...) clears it and closes the menu */
onActioned(toast: Toast): void {
this.toastService.closeToast(toast)
this.dropdown?.close()
}
}
@@ -1,85 +1,49 @@
<li ngbDropdown class="nav-item" display="dynamic" placement="bottom-end" [popperOptions]="popperOptions" (openChange)="onOpenChange($event)">
<li ngbDropdown class="nav-item" (openChange)="onOpenChange($event)">
<button class="btn navbar-action border-0" id="chatDropdown" ngbDropdownToggle aria-label="Chat" i18n-aria-label>
<i-bs width="1.3em" height="1.3em" name="chatSquareDots"></i-bs>
</button>
<div ngbDropdownMenu class="dropdown-menu-end shadow p-0 overflow-hidden" aria-labelledby="chatDropdown">
<div class="d-flex align-items-center border-bottom px-3 py-2">
<i-bs width="1em" height="1em" name="stars" class="text-primary me-2"></i-bs>
<span class="fw-semibold">
@if (documentId()) {
<ng-container i18n>Ask about this document</ng-container>
} @else {
<ng-container i18n>Ask about your documents</ng-container>
}
</span>
@if (messages().length) {
<button class="btn btn-sm btn-link text-muted ms-auto p-0" type="button" (click)="clearChat()" title="Clear chat" i18n-title>
<i-bs width="1em" height="1em" name="arrow-counterclockwise"></i-bs>
</button>
}
</div>
<div class="chat-messages px-2">
@if (!messages().length) {
<p class="text-muted text-center py-4 px-3 mb-0" i18n>Answers come from your documents, with links to their sources.</p>
}
@for (message of messages(); track message) {
<div class="message d-flex flex-row" [class.justify-content-end]="message.role === 'user'" [class.mt-3]="message.role === 'user' && !$first">
<div class="p-2 m-2" [class.bg-body-secondary]="message.role === 'user'" [class.rounded-3]="message.role === 'user'" [class.flex-grow-1]="message.role === 'assistant'">
@if (message.isStreaming && !message.content) {
<div class="d-flex align-items-center text-muted">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
@if (slowResponse()) {
<ng-container i18n>Still thinking...</ng-container>
} @else {
<ng-container i18n>Thinking...</ng-container>
}
</div>
} @else if (message.role === 'assistant') {
<div class="chat-markdown text-break text-wrap" [class.streaming]="message.isStreaming" [innerHTML]="message.content | markdown"></div>
} @else {
<span class="text-break">{{ message.content }}</span>
}
@if (message.role === 'assistant' && !message.isStreaming && message.references?.length) {
<div class="chat-references list-group mt-3">
<div class="list-group-item text-muted small py-1" i18n>Sources</div>
@for (reference of message.references; track reference.id) {
<a class="list-group-item list-group-item-action d-flex align-items-center text-primary" [routerLink]="['/documents', reference.id]" [title]="reference.title">
<i-bs width="0.9em" height="0.9em" name="file-text" class="me-2 flex-shrink-0"></i-bs><span class="text-truncate">{{ reference.title }}</span>
</a>
}
</div>
}
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown">
<div class="chat-container bg-light p-2">
<div class="chat-messages font-monospace small">
@for (message of messages(); track message) {
<div class="message d-flex flex-row small" [class.justify-content-end]="message.role === 'user'">
<div class="p-2 m-2" [class.bg-body]="message.role === 'user'">
<span class="text-break">
{{ message.content }}
@if (message.isStreaming) { <span class="blinking-cursor">|</span> }
</span>
@if (message.role === 'assistant' && message.references?.length) {
<div class="chat-references list-group mt-3">
@for (reference of message.references; track reference.id) {
<a class="list-group-item list-group-item-action d-flex text-primary text-break" [routerLink]="['/documents', reference.id]">
<i-bs width="0.9em" height="0.9em" name="file-text" class="me-1"></i-bs><span>{{ reference.title }}</span>
</a>
}
</div>
}
</div>
</div>
</div>
}
<div #scrollAnchor></div>
</div>
}
<div #scrollAnchor></div>
</div>
<form class="chat-input border-top p-2">
<label class="visually-hidden" for="chatInput" i18n>Message</label>
<div class="input-group">
<input
#chatInput
id="chatInput"
class="form-control" name="chatInput" type="text"
<form class="chat-input">
<label class="visually-hidden" for="chatInput" i18n>Message</label>
<div class="input-group">
<input
#chatInput
id="chatInput"
class="form-control form-control-sm" name="chatInput" type="text"
[placeholder]="placeholder"
[disabled]="loading()"
[ngModel]="input()"
(ngModelChange)="input.set($event)"
(keydown)="searchInputKeyDown($event)"
/>
@if (loading()) {
<button class="btn btn-outline-secondary" type="button" (click)="stopStreaming()" title="Stop" i18n-title aria-label="Stop" i18n-aria-label>
<i-bs width="1em" height="1em" name="x-lg"></i-bs>
</button>
} @else {
<button class="btn btn-primary" type="button" (click)="sendMessage()" title="Send" i18n-title aria-label="Send" i18n-aria-label>
<i-bs width="1em" height="1em" name="arrow-return-left"></i-bs>
</button>
}
</div>
</form>
/>
<button class="btn btn-sm btn-secondary" type="button" (click)="sendMessage()" [disabled]="loading()">Send</button>
</div>
</form>
</div>
</div>
</li>
@@ -1,13 +1,17 @@
.dropdown-menu {
width: min(36rem, calc(100vw - 20px));
width: var(--pngx-toast-max-width);
}
.chat-messages {
max-height: min(60dvh, 36rem);
max-height: 350px;
overflow-y: auto;
white-space: pre-wrap;
}
.chat-references {
font-family: var(--bs-font-sans-serif);
}
.dropdown-toggle::after {
display: none;
}
@@ -16,44 +20,23 @@
white-space: initial;
}
@media screen and (max-width: 575.98px) {
.chat-messages {
max-height: calc(100dvh - 12rem);
@media screen and (max-width: 400px) {
:host ::ng-deep .dropdown-menu-end {
right: -3rem;
}
}
.chat-markdown.streaming ::ng-deep {
> :last-child:not(ul, ol, table)::after,
> :is(ul, ol):last-child > li:last-child::after {
content: '';
display: inline-block;
width: 2px;
height: 1.05em;
margin-left: 2px;
background-color: currentColor;
vertical-align: -0.15em;
animation: blink 1s step-end infinite;
}
.blinking-cursor {
font-weight: bold;
font-size: 1.2em;
animation: blink 1s step-end infinite;
}
@keyframes blink {
50% {
from, to {
opacity: 0;
}
}
.chat-markdown ::ng-deep {
h1, h2, h3, h4, h5, h6 {
font-size: 1em;
font-weight: bold;
}
th, td {
padding: 0.15rem 0.4rem;
border: 1px solid var(--bs-border-color);
}
> :last-child {
margin-bottom: 0;
50% {
opacity: 1;
}
}
@@ -80,16 +80,8 @@ describe('ChatComponent', () => {
expect(component.messages()[0].content).toBe('Hello')
expect(component.loading()).toBe(true)
fixture.detectChanges()
const messagesEl = fixture.nativeElement.querySelector('.chat-messages')
expect(messagesEl.querySelector('.spinner-border')).not.toBeNull()
expect(messagesEl.textContent).toContain('Thinking...')
mockStream$.next('Hi')
expect(component.messages()[1].content).toBe('H')
fixture.detectChanges()
expect(messagesEl.querySelector('.spinner-border')).toBeNull()
expect(messagesEl.querySelector('.chat-markdown.streaming')).not.toBeNull()
mockStream$.next('Hi there')
// advance time to process the typewriter effect
await jest.runAllTimersAsync()
@@ -104,8 +96,6 @@ describe('ChatComponent', () => {
await fixture.whenStable()
expect(component.loading()).toBe(false)
expect(component.messages()[1].isStreaming).toBe(false)
fixture.detectChanges()
expect(messagesEl.querySelector('.chat-markdown.streaming')).toBeNull()
expect(fixture.nativeElement.querySelector('#chatInput').disabled).toBe(
false
)
@@ -133,10 +123,6 @@ describe('ChatComponent', () => {
mockStream$.next(
`Hi there${CHAT_METADATA_DELIMITER}{"references":[{"id":42,"title":"Bread Recipe"}]}`
)
fixture.detectChanges()
expect(fixture.nativeElement.querySelector('.chat-references')).toBeNull()
mockStream$.complete()
jest.advanceTimersByTime(1000)
fixture.detectChanges()
@@ -170,96 +156,13 @@ describe('ChatComponent', () => {
component.input.set('Hello')
component.sendMessage()
mockStream$.next('Partial')
mockStream$.error('Error')
expect(component.messages()[1].content).toBe(
'Partial\n\n⚠️ Error receiving response.'
expect(component.messages()[1].content).toContain(
'⚠️ Error receiving response.'
)
expect(component.loading()).toBe(false)
})
it('should keep streaming until the typewriter catches up', () => {
component.input.set('Hello')
component.sendMessage()
mockStream$.next('Hi there')
mockStream$.complete()
expect(component.messages()[1].isStreaming).toBe(true)
expect(component.loading()).toBe(true)
jest.advanceTimersByTime(1000)
expect(component.messages()[1].content).toBe('Hi there')
expect(component.messages()[1].isStreaming).toBe(false)
expect(component.loading()).toBe(false)
})
it('should show a slow response message while waiting', () => {
component.input.set('Hello')
component.sendMessage()
fixture.detectChanges()
const messagesEl = fixture.nativeElement.querySelector('.chat-messages')
expect(messagesEl.textContent).not.toContain('Still thinking...')
jest.advanceTimersByTime(8000)
fixture.detectChanges()
expect(messagesEl.textContent).toContain('Still thinking...')
mockStream$.next('Hi')
expect(component.slowResponse()).toBe(false)
})
it('should stop streaming and keep the partial response', () => {
component.input.set('Hello')
component.sendMessage()
fixture.detectChanges()
const stopButton = fixture.nativeElement.querySelector('.chat-input button')
expect(stopButton.getAttribute('aria-label')).toBe('Stop')
mockStream$.next('Hi there')
jest.advanceTimersByTime(20)
stopButton.click()
expect(mockStream$.observed).toBe(false)
expect(component.messages()[1].content).toBe('Hi there')
expect(component.messages()[1].isStreaming).toBe(false)
expect(component.loading()).toBe(false)
jest.advanceTimersByTime(1000)
expect(component.messages()[1].content).toBe('Hi there')
})
it('should drop the empty response when stopped before any content', () => {
component.input.set('Hello')
component.sendMessage()
component.stopStreaming()
expect(component.messages()).toHaveLength(1)
expect(component.loading()).toBe(false)
component.stopStreaming() // no-op once stopped
expect(component.messages()).toHaveLength(1)
})
it('should clear the conversation', () => {
component.input.set('Hello')
component.sendMessage()
mockStream$.next('Hi there')
fixture.detectChanges()
fixture.nativeElement.querySelector('button[title="Clear chat"]').click()
expect(component.messages()).toHaveLength(0)
expect(component.loading()).toBe(false)
expect(mockStream$.observed).toBe(false)
fixture.detectChanges()
jest.advanceTimersByTime(10)
expect(document.activeElement).toBe(
fixture.nativeElement.querySelector('#chatInput')
)
expect(fixture.nativeElement.textContent).toContain(
'Answers come from your documents'
)
})
it('should enqueue typewriter chunks correctly', () => {
const message = { content: '', role: 'assistant', isStreaming: true }
component.enqueueTypewriter(null, message as any) // coverage for null
@@ -304,33 +207,4 @@ describe('ChatComponent', () => {
component.searchInputKeyDown(event)
expect(component.sendMessage).not.toHaveBeenCalled()
})
it('should render markdown for assistant messages only', () => {
component.messages.set([
{ role: 'user', content: '**user**' },
{ role: 'assistant', content: '**assistant** <script>x</script>' },
])
fixture.detectChanges()
const el: HTMLElement = fixture.nativeElement
expect(el.querySelector('.chat-markdown strong')?.textContent).toBe(
'assistant'
)
expect(el.querySelector('.chat-markdown script')).toBeNull()
expect(el.textContent).toContain('**user**')
})
it('should keep the destination hostname visible after sanitizing chat links', () => {
component.messages.set([
{
role: 'assistant',
content:
'[View original document](https://otherdomain.example/?data=secret)',
},
])
fixture.detectChanges()
const el: HTMLElement = fixture.nativeElement
const link = el.querySelector('.chat-markdown a')
expect(link?.textContent).toBe('View original document')
expect(link?.nextElementSibling?.textContent).toBe('(otherdomain.example)')
})
})
@@ -10,16 +10,12 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NavigationEnd, Router, RouterModule } from '@angular/router'
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { filter, map, Subscription } from 'rxjs'
import { MarkdownPipe } from 'src/app/pipes/markdown.pipe'
import { filter, map } from 'rxjs'
import {
ChatMessage,
ChatService,
parseChatResponse,
} from 'src/app/services/chat.service'
import { pngxPopperOptions } from 'src/app/utils/popper-options'
const SLOW_RESPONSE_DELAY_MS = 8000
@Component({
selector: 'pngx-chat',
@@ -29,7 +25,6 @@ const SLOW_RESPONSE_DELAY_MS = 8000
RouterModule,
NgxBootstrapIconsModule,
NgbDropdownModule,
MarkdownPipe,
],
templateUrl: './chat.component.html',
styleUrl: './chat.component.scss',
@@ -39,8 +34,6 @@ export class ChatComponent implements OnInit {
readonly loading = signal(false)
readonly input = signal('')
readonly documentId = signal<number>(undefined)
readonly slowResponse = signal(false)
public popperOptions = pngxPopperOptions
private chatService: ChatService = inject(ChatService)
private router: Router = inject(Router)
@@ -50,11 +43,6 @@ export class ChatComponent implements OnInit {
private typewriterBuffer: string[] = []
private typewriterActive = false
private typewriterTimeout: ReturnType<typeof setTimeout>
private streamSubscription: Subscription
private streamingMessage: ChatMessage
private streamFinished = false
private slowResponseTimeout: ReturnType<typeof setTimeout>
public get placeholder(): string {
return this.documentId()
@@ -93,96 +81,47 @@ export class ChatComponent implements OnInit {
}
this.messages.update((messages) => [...messages, assistantMessage])
this.loading.set(true)
this.streamingMessage = assistantMessage
this.streamFinished = false
this.slowResponseTimeout = setTimeout(
() => this.slowResponse.set(true),
SLOW_RESPONSE_DELAY_MS
)
let lastVisibleContent = ''
this.streamSubscription = this.chatService
.streamChat(this.documentId(), this.input())
.subscribe({
next: (chunk) => {
this.clearSlowResponse()
const nextResponse = parseChatResponse(chunk)
this.chatService.streamChat(this.documentId(), this.input()).subscribe({
next: (chunk) => {
const nextResponse = parseChatResponse(chunk)
if (nextResponse.content.length < lastVisibleContent.length) {
this.resetTypewriter(assistantMessage, nextResponse.content)
lastVisibleContent = nextResponse.content
} else {
const visibleDelta = nextResponse.content.substring(
lastVisibleContent.length
)
lastVisibleContent = nextResponse.content
this.enqueueTypewriter(visibleDelta, assistantMessage)
}
if (nextResponse.content.length < lastVisibleContent.length) {
this.resetTypewriter(assistantMessage, nextResponse.content)
lastVisibleContent = nextResponse.content
} else {
const visibleDelta = nextResponse.content.substring(
lastVisibleContent.length
)
lastVisibleContent = nextResponse.content
this.enqueueTypewriter(visibleDelta, assistantMessage)
}
assistantMessage.references = nextResponse.references
this.notifyMessagesChanged()
},
error: () => {
this.flushTypewriter(assistantMessage)
assistantMessage.content += '\n\n⚠️ Error receiving response.'
this.finishStreaming(assistantMessage)
},
complete: () => {
// let the typewriter catch up before hiding the cursor
this.streamFinished = true
if (!this.typewriterActive) this.finishStreaming(assistantMessage)
},
})
assistantMessage.references = nextResponse.references
this.notifyMessagesChanged()
},
error: () => {
assistantMessage.content += '\n\n⚠️ Error receiving response.'
assistantMessage.isStreaming = false
this.notifyMessagesChanged()
this.loading.set(false)
},
complete: () => {
assistantMessage.isStreaming = false
this.notifyMessagesChanged()
this.loading.set(false)
this.scrollToBottom()
},
})
this.input.set('')
}
stopStreaming(): void {
const message = this.streamingMessage
if (!message?.isStreaming) return
this.streamSubscription?.unsubscribe()
this.flushTypewriter(message)
if (!message.content) {
this.messages.update((messages) => messages.filter((m) => m !== message))
}
this.finishStreaming(message)
}
clearChat(): void {
this.stopStreaming()
this.messages.set([])
setTimeout(() => this.chatInput?.nativeElement.focus())
}
private finishStreaming(message: ChatMessage): void {
this.clearSlowResponse()
this.streamFinished = false
message.isStreaming = false
this.notifyMessagesChanged()
this.loading.set(false)
this.scrollToBottom()
}
private clearSlowResponse(): void {
clearTimeout(this.slowResponseTimeout)
this.slowResponse.set(false)
}
private flushTypewriter(message: ChatMessage): void {
message.content += this.typewriterBuffer.join('')
this.cancelTypewriter()
}
private cancelTypewriter(): void {
clearTimeout(this.typewriterTimeout)
private resetTypewriter(message: ChatMessage, content: string): void {
this.typewriterBuffer = []
this.typewriterActive = false
}
private resetTypewriter(message: ChatMessage, content: string): void {
this.cancelTypewriter()
message.content = content
this.notifyMessagesChanged()
this.scrollToBottom()
@@ -202,7 +141,6 @@ export class ChatComponent implements OnInit {
playTypewriter(message: ChatMessage): void {
if (this.typewriterBuffer.length === 0) {
this.typewriterActive = false
if (this.streamFinished) this.finishStreaming(message)
return
}
@@ -211,8 +149,7 @@ export class ChatComponent implements OnInit {
this.notifyMessagesChanged()
this.scrollToBottom()
// 10ms per character
this.typewriterTimeout = setTimeout(() => this.playTypewriter(message), 10)
setTimeout(() => this.playTypewriter(message), 10) // 10ms per character
}
private notifyMessagesChanged(): void {
@@ -110,7 +110,7 @@ describe('CustomFieldsDropdownComponent', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const getFieldsSpy = jest.spyOn(
CustomFieldsDropdownComponent.prototype as any,
'getFields'
@@ -129,9 +129,7 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio
modal.componentInstance.succeeded
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((newField) => {
this.toastService.showSuccess(
$localize`Saved field "${newField.name}".`
)
this.toastService.showInfo($localize`Saved field "${newField.name}".`)
this.customFieldsService.clearCache()
this.getFields()
this.created.emit(newField)
@@ -33,44 +33,21 @@
<ng-template #comparisonValueTemplate let-atom="atom">
@if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Date) {
@let relativeDate = getRelativeDate(atom);
<button class="btn btn-sm btn-outline-secondary" [class.active]="relativeDate" [class.rounded-end]="relativeDate" [attr.aria-pressed]="!!relativeDate" (click)="toggleRelativeDate(atom)" type="button" title="Relative date" i18n-title>
<i-bs name="clock-history"></i-bs>
<input class="form-control" placeholder="yyyy-mm-dd"
[(ngModel)]="atom.value"
[ngModelOptions]="{standalone: true}"
ngbDatepicker
#d="ngbDatepicker"
[footerTemplate]="datePickerFooterTemplate" />
<button class="btn btn-sm btn-outline-secondary rounded-end" (click)="d.toggle()" type="button" aria-label="Open date picker" i18n-aria-label>
<i-bs name="calendar-event"></i-bs>
</button>
@if (relativeDate) {
<div class="input-group input-group-sm w-100 order-1 mt-1">
@if (relativeDate.unit.includes('{n}')) {
<input class="form-control w-25 flex-grow-0" type="number" min="0" aria-label="Amount" i18n-aria-label
[ngModel]="relativeDate.amount"
(ngModelChange)="updateRelativeDate(atom, { amount: $event })"
[ngModelOptions]="{standalone: true}" />
}
<select class="form-select" aria-label="Unit" i18n-aria-label
[ngModel]="relativeDate.unit"
(ngModelChange)="updateRelativeDate(atom, { unit: $event })"
[ngModelOptions]="{standalone: true}">
@for (unit of relativeDateUnits; track unit.id) {
<option [ngValue]="unit.id">{{unit.label}}</option>
}
</select>
<ng-template #datePickerFooterTemplate>
<div class="btn-group-xs border-top p-2 d-flex">
<button type="button" class="btn btn-primary" (click)="atom.value = today; d.close()" i18n>Today</button>
<button type="button" class="btn btn-secondary ms-auto" (click)="d.close()" i18n>Close</button>
</div>
} @else {
<input class="form-control" placeholder="yyyy-mm-dd"
[(ngModel)]="atom.value"
[ngModelOptions]="{standalone: true}"
ngbDatepicker
#d="ngbDatepicker"
[footerTemplate]="datePickerFooterTemplate" />
<button class="btn btn-sm btn-outline-secondary rounded-end" (click)="d.toggle()" type="button" aria-label="Open date picker" i18n-aria-label>
<i-bs name="calendar-event"></i-bs>
</button>
<ng-template #datePickerFooterTemplate>
<div class="btn-group-xs border-top p-2 d-flex">
<button type="button" class="btn btn-primary" (click)="atom.value = today; d.close()" i18n>Today</button>
<button type="button" class="btn btn-secondary ms-auto" (click)="d.close()" i18n>Close</button>
</div>
</ng-template>
}
</ng-template>
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Float || getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Integer) {
<input class="w-25 form-control rounded-end" type="number" [(ngModel)]="atom.value" [ngModelOptions]="{standalone: true}" [disabled]="disabled">
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Boolean) {
@@ -174,10 +151,10 @@
<input class="w-25 form-control rounded-end" type="text" [(ngModel)]="atom.value" [ngModelOptions]="{standalone: true}" [disabled]="disabled">
}
}
<button class="btn btn-link btn-sm text-danger pe-0" type="button" (click)="removeElement(atom)" [disabled]="disabled" aria-label="Remove query" i18n-aria-label>
<i-bs name="x-circle"></i-bs>
</button>
</div>
<button class="btn btn-link btn-sm text-danger pe-0 align-self-center" type="button" (click)="removeElement(atom)" [disabled]="disabled" aria-label="Remove query" i18n-aria-label>
<i-bs name="x-circle"></i-bs>
</button>
</ng-template>
<ng-template #queryExpression let-expression="expression">
@@ -6,8 +6,6 @@
}
:host ::ng-deep .ng-select-container {
border-top-left-radius: var(--bs-border-radius) !important;
border-bottom-left-radius: var(--bs-border-radius) !important;
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
height: 100% !important;
@@ -3,7 +3,7 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'
import { LOCALE_ID } from '@angular/core'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbDateAdapter, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select'
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { of } from 'rxjs'
@@ -11,7 +11,6 @@ import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import {
CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP,
CustomFieldQueryLogicalOperator,
CustomFieldQueryOperator,
CustomFieldQueryOperatorGroups,
} from 'src/app/data/custom-field-query'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
@@ -20,7 +19,6 @@ import {
CustomFieldQueryElement,
CustomFieldQueryExpression,
} from 'src/app/utils/custom-field-query-element'
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
import {
CustomFieldQueriesModel,
CustomFieldsQueryDropdownComponent,
@@ -50,12 +48,6 @@ const customFields = [
data_type: CustomFieldDataType.Monetary,
extra_data: { default_currency: 'EUR' },
},
{
id: 4,
name: 'Test Date Field',
data_type: CustomFieldDataType.Date,
extra_data: {},
},
]
describe('CustomFieldsQueryDropdownComponent', () => {
@@ -77,7 +69,6 @@ describe('CustomFieldsQueryDropdownComponent', () => {
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
{ provide: LOCALE_ID, useValue: 'de' },
{ provide: NgbDateAdapter, useClass: ISODateAdapter },
],
}).compileComponents()
@@ -246,74 +237,6 @@ describe('CustomFieldsQueryDropdownComponent', () => {
expect(component.selectionModel.queries.length).toBe(1)
})
it('should build relative date values from amount and unit', async () => {
component.useDropdown = false
const atom = new CustomFieldQueryAtom([
4,
CustomFieldQueryOperator.GreaterThanOrEqual,
'2026-10-22',
])
component.selectionModel.addAtom(atom)
fixture.detectChanges()
await fixture.whenStable()
const query = (selector: string) =>
fixture.nativeElement.querySelector(selector)
const toggle: HTMLButtonElement = query('button[title="Relative date"]')
expect(query('[ngbDatepicker]')).not.toBeNull()
toggle.click()
fixture.detectChanges()
await fixture.whenStable()
expect(atom.value).toBeNull()
const amount: HTMLInputElement = query('input[aria-label="Amount"]')
const unit: HTMLSelectElement = query('select[aria-label="Unit"]')
amount.value = '30'
amount.dispatchEvent(new Event('input'))
expect(atom.serialize()).toEqual([4, 'gte', '30 days ago'])
unit.selectedIndex = 6
unit.dispatchEvent(new Event('change'))
expect(atom.serialize()).toEqual([4, 'gte', 'in 30 weeks'])
unit.selectedIndex = 0
unit.dispatchEvent(new Event('change'))
fixture.detectChanges()
expect(atom.serialize()).toEqual([4, 'gte', 'today'])
expect(query('input[aria-label="Amount"]')).toBeNull()
toggle.click()
fixture.detectChanges()
expect(atom.value).toBeNull()
expect(query('[ngbDatepicker]')).not.toBeNull()
})
it('should restore a saved relative date value', async () => {
component.useDropdown = false
const atom = new CustomFieldQueryAtom([
4,
CustomFieldQueryOperator.Exact,
'in 2 months',
])
component.selectionModel.addAtom(atom)
fixture.detectChanges()
await fixture.whenStable()
expect(component.getRelativeDate(atom)).toEqual({
amount: 2,
unit: 'in {n} months',
})
expect(
fixture.nativeElement.querySelector('input[aria-label="Amount"]').value
).toBe('2')
const today = new CustomFieldQueryAtom([
4,
CustomFieldQueryOperator.Exact,
'today',
])
expect(component.getRelativeDate(today)).toEqual({
amount: null,
unit: 'today',
})
})
describe('CustomFieldQueriesModel', () => {
let model: CustomFieldQueriesModel
@@ -49,26 +49,6 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
import { DocumentLinkComponent } from '../input/document-link/document-link.component'
interface RelativeDate {
// The expression sent to the API, with {n} standing in for the amount
amount: number | null
unit: string
}
function parseRelativeDate(
value: string,
unit: string
): RelativeDate | undefined {
const [prefix, suffix] = unit.split('{n}')
if (suffix === undefined) {
return value === unit ? { amount: null, unit } : undefined
}
if (!value.startsWith(prefix) || !value.endsWith(suffix)) return undefined
const amount = value.slice(prefix.length, value.length - suffix.length)
if (!amount || !Number.isInteger(+amount) || +amount < 0) return undefined
return { amount: +amount, unit }
}
export class CustomFieldQueriesModel {
private readonly _queries = signal<CustomFieldQueryElement[]>([])
private rootSubscriptions: Subscription[] = []
@@ -325,58 +305,6 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
public readonly today: string = new Date().toLocaleDateString('en-CA')
public readonly relativeDateUnits = [
{ id: 'today', label: $localize`Today` },
{ id: '{n} days ago', label: $localize`days ago` },
{ id: '{n} weeks ago', label: $localize`weeks ago` },
{ id: '{n} months ago', label: $localize`months ago` },
{ id: '{n} years ago', label: $localize`years ago` },
{ id: 'in {n} days', label: $localize`days from now` },
{ id: 'in {n} weeks', label: $localize`weeks from now` },
{ id: 'in {n} months', label: $localize`months from now` },
{ id: 'in {n} years', label: $localize`years from now` },
]
private readonly relativeDates = new Map<string, RelativeDate>()
getRelativeDate(atom: CustomFieldQueryAtom): RelativeDate | undefined {
if (!this.relativeDates.has(atom.id) && typeof atom.value === 'string') {
for (const { id } of this.relativeDateUnits) {
const relativeDate = parseRelativeDate(atom.value, id)
if (relativeDate) {
this.relativeDates.set(atom.id, relativeDate)
break
}
}
}
return this.relativeDates.get(atom.id)
}
toggleRelativeDate(atom: CustomFieldQueryAtom) {
if (this.getRelativeDate(atom)) {
this.relativeDates.delete(atom.id)
atom.value = null
} else {
this.relativeDates.set(atom.id, { amount: null, unit: '{n} days ago' })
this.updateRelativeDate(atom, {})
}
}
updateRelativeDate(
atom: CustomFieldQueryAtom,
change: Partial<RelativeDate>
) {
const { amount, unit } = Object.assign(
this.relativeDates.get(atom.id),
change
)
if (!unit.includes('{n}')) {
atom.value = unit
} else {
atom.value = amount === null ? null : unit.replace('{n}', `${amount}`)
}
}
public customFieldSearchFn = (term: string, field: CustomField): boolean =>
matchesSearchText(field?.name, term)
@@ -15,7 +15,7 @@
</a>
}
</div>
<div class="input-group input-group-sm ps-1 pe-2">
<div class="input-group input-group-sm small ps-1 pe-2">
<ng-select class="w-100" name="createdRelativeDate"
[items]="relativeDates" [(ngModel)]="createdRelativeDate"
bindValue="id"
@@ -92,7 +92,7 @@
</a>
}
</div>
<div class="input-group input-group-sm ps-1 pe-2">
<div class="input-group input-group-sm small ps-1 pe-2">
<ng-select class="w-100" name="addedRelativeDate"
[items]="relativeDates" [(ngModel)]="addedRelativeDate"
bindValue="id"
@@ -151,7 +151,7 @@ describe('UserEditDialogComponent', () => {
'deactivateTotp'
)
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
deactivateSpy.mockReturnValueOnce(throwError(() => new Error('error')))
component.deactivateTotp()
expect(deactivateSpy).toHaveBeenCalled()
@@ -132,7 +132,7 @@ export class UserEditDialogComponent
next: (result) => {
this.totpLoading.set(false)
if (result) {
this.toastService.showSuccess($localize`Totp deactivated`)
this.toastService.showInfo($localize`Totp deactivated`)
this.object.is_mfa_enabled = false
} else {
this.toastService.showError($localize`Totp deactivation failed`)
@@ -209,7 +209,6 @@
@if (isTagsFilter(filter.get('type').value)) {
<pngx-input-tags
[allowCreate]="false"
[preserveHierarchy]="false"
[title]="null"
formControlName="values"
></pngx-input-tags>
@@ -321,7 +320,7 @@
<h6 class="form-label" i18n>Remove tags</h6>
<pngx-input-switch i18n-title title="Remove all" [horizontal]="true" formControlName="remove_all_tags"></pngx-input-switch>
<div class="mt-n3">
<pngx-input-tags [allowCreate]="false" [preserveHierarchy]="false" title="" formControlName="remove_tags"></pngx-input-tags>
<pngx-input-tags [allowCreate]="false" title="" formControlName="remove_tags"></pngx-input-tags>
</div>
<h6 class="form-label" i18n>Remove correspondents</h6>
@@ -52,7 +52,7 @@ describe('EmailDocumentDialogComponent', () => {
it('should support sending single document via email, showing error if needed', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
const toastSuccessSpy = jest.spyOn(toastService, 'showInfo')
component.documentIds.set([1])
component.emailAddress = 'hello@paperless-ngx.com'
component.emailSubject = 'Hello'
@@ -73,7 +73,7 @@ describe('EmailDocumentDialogComponent', () => {
it('should support sending multiple documents via email, showing appropriate messages', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
const toastSuccessSpy = jest.spyOn(toastService, 'showInfo')
component.documentIds.set([1, 2, 3])
component.emailAddress = 'hello@paperless-ngx.com'
component.emailSubject = 'Hello'
@@ -50,7 +50,7 @@ export class EmailDocumentDialogComponent extends LoadingComponentWithPermission
this.emailSubject = ''
this.emailMessage = ''
this.close()
this.toastService.showSuccess($localize`Email sent`)
this.toastService.showInfo($localize`Email sent`)
},
error: (e) => {
this.loading.set(false)
@@ -51,20 +51,20 @@
@if (editing) {
@if (filteredItems.length === 0 && createRef !== undefined && filterText?.length > 0) {
<button class="list-group-item list-group-item-action bg-light" (click)="createClicked()" [disabled]="disabled">
<span class="ms-2"><ng-container i18n>Create</ng-container> "{{filterText}}"</span>
<small class="ms-2"><ng-container i18n>Create</ng-container> "{{filterText}}"</small>
<i-bs width="1.5em" height="1em" name="plus"></i-bs>
</button>
}
@if (filteredItems.length > 0) {
<button class="list-group-item list-group-item-action bg-light d-flex align-items-center" (click)="applyClicked()" [disabled]="!modelIsDirty() || disabled">
<span class="ms-2" [ngClass]="{'fw-bold': modelIsDirty()}" i18n>Apply</span>
<small class="ms-2" [ngClass]="{'fw-bold': modelIsDirty()}" i18n>Apply</small>
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
</button>
}
}
@if (extraButtonTitle && (showExtraButtonIfEmpty || filteredItems?.length > 0)) {
<button class="list-group-item list-group-item-action bg-light d-flex align-items-center" (click)="extraButtonClicked($event)" [disabled]="disabled">
<span class="ms-2 fw-bold">{{extraButtonTitle}}</span>
<small class="ms-2 fw-bold">{{extraButtonTitle}}</small>
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
</button>
}
@@ -29,7 +29,7 @@
line-height: 1;
small {
font-size: 0.65rem;
font-size: 65%;
}
}
@@ -23,7 +23,7 @@
@if (isTag) {
<pngx-tag [tag]="item" [clickable]="false"></pngx-tag>
} @else {
{{item.name}}
<small>{{item.name}}</small>
}
</div>
</div>
@@ -23,9 +23,4 @@
.badge {
flex-shrink: 0;
font-size: 0.75rem;
}
pngx-tag {
font-size: 1rem;
}
@@ -1,7 +1,5 @@
@if (!accordion) {
@if (title?.length > 0) {
<h5>{{title}}</h5>
}
<h5 i18n>Permissions</h5>
<ng-container [ngTemplateOutlet]="permissionsForm"></ng-container>
}
@if (accordion) {
@@ -59,9 +59,6 @@ export class PermissionsFormComponent
@Input()
accordion: boolean = false
@Input()
title: string = $localize`Permissions`
form = new FormGroup({
owner: new FormControl(null),
set_permissions: new FormGroup({
@@ -222,22 +222,6 @@ describe('TagsComponent', () => {
expect(propagated).toEqual([2, 1])
})
it('should not add parents or remove children when not preserving hierarchy', () => {
const parent: Tag = { id: 1, name: 'parent' }
const child: Tag = { id: 2, name: 'child', parent: 1 }
parent.children = [child]
component.tags = [parent, child]
component.preserveHierarchy = false
component.value = []
component.addTag(2)
expect(component.value).toEqual([2])
component.value = [1, 2]
component.removeTag(1)
expect(component.value).toEqual([2])
})
it('should not duplicate parents when adding sibling nested tags', () => {
const root: Tag = { id: 1, name: 'root' }
const parent: Tag = { id: 2, name: 'parent', parent: 1 }
@@ -109,9 +109,6 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
@Input()
multiple: boolean = true
@Input()
preserveHierarchy: boolean = true
@Output()
filterDocuments = new EventEmitter<Tag[]>()
@@ -151,9 +148,7 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
oldValue.splice(index, 1)
// remove children
if (this.preserveHierarchy) {
oldValue = this.removeChildren(oldValue, tag)
}
oldValue = this.removeChildren(oldValue, tag)
this.value = [...oldValue]
this.onChange(this.value)
@@ -172,7 +167,7 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
}
public onAdd(tag: Tag) {
if (this.preserveHierarchy && tag?.parent) {
if (tag?.parent) {
// add all parents recursively
const parent = this.getTag(tag.parent)
if (parent && !this.value.includes(parent.id)) {
@@ -3,13 +3,13 @@
<h3 class="d-flex align-items-center mb-2 mb-md-1" style="line-height: 1.4">
<span class="text-truncate">{{title()}}</span>
@if (id()) {
<button type="button" class="badge border-0 bg-primary text-primary-text-contrast ms-3 small fs-normal" (click)="copyID()" title="Copy ID" i18n-title>
<span class="badge bg-primary text-primary-text-contrast ms-3 small fs-normal cursor-pointer" (click)="copyID()">
@if (copied()) {
<i-bs width="1em" height="1em" name="clipboard-check" class="me-1"></i-bs><ng-container i18n>Copied!</ng-container>
} @else {
ID: {{id()}}
}
</button>
</span>
}
@if (subTitle()) {
<span class="page-subtitle h6 mb-0 mt-1 fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle()}}</span>
@@ -43,13 +43,6 @@ describe('PageHeaderComponent', () => {
expect(titleSpy).toHaveBeenCalledWith(`Foo Bar - ${environment.appTitle}`)
})
it('should set html title to just the app title until a title is available', () => {
const titleSpy = jest.spyOn(titleService, 'setTitle')
fixture.componentRef.setInput('title', undefined)
fixture.detectChanges()
expect(titleSpy).toHaveBeenCalledWith(environment.appTitle)
})
it('should copy id to clipboard, reset after 3 seconds', () => {
jest.useFakeTimers()
fixture.componentRef.setInput('id', 42)
@@ -27,10 +27,7 @@ export class PageHeaderComponent {
constructor() {
effect(() => {
const title = this.title()
this.titleService.setTitle(
title ? `${title} - ${environment.appTitle}` : environment.appTitle
)
this.titleService.setTitle(`${this.title()} - ${environment.appTitle}`)
})
}
@@ -12,7 +12,7 @@
}
</div>
<div class="me-1">
<span i18n>All</span>
<small i18n>All</small>
</div>
</button>
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SELF)" [disabled]="disabled">
@@ -22,7 +22,7 @@
}
</div>
<div class="me-1">
{{ownerFilterLabel}}
<small>{{ownerFilterLabel}}</small>
</div>
</button>
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.NOT_SELF)" [disabled]="disabled">
@@ -32,7 +32,7 @@
}
</div>
<div class="me-1">
{{ownerExclusionFilterLabel}}
<small>{{ownerExclusionFilterLabel}}</small>
</div>
</button>
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SHARED_BY_ME)" [disabled]="disabled">
@@ -42,7 +42,7 @@
}
</div>
<div class="me-1">
{{sharedByFilterLabel}}
<small>{{sharedByFilterLabel}}</small>
</div>
</button>
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.UNOWNED)" [disabled]="disabled">
@@ -52,7 +52,7 @@
}
</div>
<div class="me-1">
<span i18n>Unowned</span>
<small i18n>Unowned</small>
</div>
</button>
<button *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }" class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" [disabled]="disabled">
@@ -67,7 +67,7 @@
<div class="me-1 w-100">
<ng-select
name="user"
class="user-select"
class="user-select small"
[ngModel]="selectionModel.includeUsers()"
(ngModelChange)="selectionModel.includeUsers.set($event)"
[disabled]="disabled"
@@ -87,7 +87,7 @@
<div class="list-group-item list-group-item-action d-flex align-items-center p-2 ps-3 border-bottom-0 border-start-0 border-end-0">
<div class="form-check form-switch w-100">
<input type="checkbox" class="form-check-input" id="hideUnowned" [ngModel]="selectionModel.hideUnowned()" (ngModelChange)="selectionModel.hideUnowned.set($event)" (change)="onChange()" [disabled]="disabled">
<label class="form-check-label w-100" for="hideUnowned"><ng-container i18n>Hide unowned</ng-container></label>
<label class="form-check-label w-100" for="hideUnowned"><small i18n>Hide unowned</small></label>
</div>
</div>
}
@@ -100,7 +100,7 @@ describe('ProfileEditDialogComponent', () => {
expect(errorSpy).toHaveBeenCalled()
updateSpy.mockClear()
const infoSpy = jest.spyOn(toastService, 'showSuccess')
const infoSpy = jest.spyOn(toastService, 'showInfo')
component.form.patchValue(newProfile)
updateSpy.mockReturnValueOnce(of(newProfile))
component.save()
@@ -373,7 +373,7 @@ describe('ProfileEditDialogComponent', () => {
it('should activate totp', () => {
const activateSpy = jest.spyOn(profileService, 'activateTotp')
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const error = new Error('failed to activate totp')
activateSpy.mockReturnValueOnce(throwError(() => error))
component.totpSettings.set({
@@ -405,7 +405,7 @@ describe('ProfileEditDialogComponent', () => {
it('should deactivate totp', () => {
const deactivateSpy = jest.spyOn(profileService, 'deactivateTotp')
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const error = new Error('failed to deactivate totp')
deactivateSpy.mockReturnValueOnce(throwError(() => error))
component.deactivateTotp()
@@ -204,7 +204,7 @@ export class ProfileEditDialogComponent
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({
next: () => {
this.toastService.showSuccess($localize`Profile updated successfully`)
this.toastService.showInfo($localize`Profile updated successfully`)
if (passwordChanged) {
this.toastService.showInfo(
$localize`Password has been changed, you will be logged out momentarily.`
@@ -307,9 +307,7 @@ export class ProfileEditDialogComponent
this.recoveryCodes.set(activationResponse.recovery_codes)
this.form.get('totp_code').enable()
if (activationResponse.success) {
this.toastService.showSuccess(
$localize`TOTP activated successfully`
)
this.toastService.showInfo($localize`TOTP activated successfully`)
} else {
this.toastService.showError($localize`Error activating TOTP`)
}
@@ -333,9 +331,7 @@ export class ProfileEditDialogComponent
this.isTotpEnabled.set(!success)
this.recoveryCodes.set(null)
if (success) {
this.toastService.showSuccess(
$localize`TOTP deactivated successfully`
)
this.toastService.showInfo($localize`TOTP deactivated successfully`)
} else {
this.toastService.showError($localize`Error deactivating TOTP`)
}
@@ -1,33 +1,22 @@
<ngb-toast
[autohide]="false"
[class.mb-2]="autohide"
[class.toast-list-item]="!autohide"
[class.toast-new]="isNew"
[class.toast-success]="toast.level === ToastLevel.Success"
[class.toast-error]="toast.level === ToastLevel.Error"
(mouseenter)="setHovered(true)"
(mouseleave)="setHovered(false)"
(focusin)="setFocused(true)"
(focusout)="setFocused(false)"
[autohide]="autohide"
[delay]="toast.delay"
[class]="toast.classname"
[class.mb-2]="true"
(shown)="onShown(toast)"
(hidden)="hidden.emit(toast)">
@if (autohide) {
<ngb-progressbar class="position-absolute w-100 start-0 bottom-0 pe-none" [max]="toast.delay" [value]="delayRemaining()"></ngb-progressbar>
<span class="visually-hidden">{{ delayRemaining() / 1000 | number: '1.0-0' }} seconds</span>
<ngb-progressbar class="position-absolute h-100 w-100 top-90 start-0 bottom-0 end-0 pe-none" type="dark" [max]="toast.delay" [value]="toast.delayRemaining"></ngb-progressbar>
<span class="visually-hidden">{{ toast.delayRemaining / 1000 | number: '1.0-0' }} seconds</span>
}
<div class="d-flex align-items-top">
@switch (toast.level) {
@case (ToastLevel.Success) {
<i-bs width="0.9em" height="0.9em" name="check-circle" class="toast-icon"></i-bs>
}
@case (ToastLevel.Error) {
<i-bs width="0.9em" height="0.9em" name="exclamation-triangle" class="toast-icon"></i-bs>
}
@default {
<i-bs width="0.9em" height="0.9em" name="info-circle" class="toast-icon"></i-bs>
}
@if (!toast.error) {
<i-bs width="0.9em" height="0.9em" name="info-circle"></i-bs>
}
<div class="toast-content">
@if (toast.error) {
<i-bs width="0.9em" height="0.9em" name="exclamation-triangle"></i-bs>
}
<div>
<p class="ms-2 mb-0 text-break">{{toast.content}}</p>
@if (toast.error) {
<details class="ms-2">
@@ -59,12 +48,9 @@
</details>
}
@if (toast.action) {
<button class="btn btn-sm toast-action ms-2 mt-2" (click)="actioned.emit(toast); toast.action()">{{toast.actionName}}</button>
<p class="mb-0 mt-2"><button class="btn btn-sm btn-outline-secondary" (click)="closed.emit(toast); toast.action()">{{toast.actionName}}</button></p>
}
</div>
@if (!autohide && toast.timestamp) {
<span class="toast-time small text-body-secondary text-nowrap ms-auto ps-2">{{relativeTimestamp}}</span>
}
<button type="button" class="btn-close ms-auto flex-shrink-0" data-bs-dismiss="toast" aria-label="Close" (click)="closed.emit(toast);"></button>
</div>
</ngb-toast>
@@ -2,105 +2,19 @@
position: relative;
}
.toast {
--pngx-toast-accent: var(--pngx-toast-info);
background-color: var(--pngx-toast-bg);
color: var(--bs-body-color);
overflow: hidden;
&.toast-success {
--pngx-toast-accent: var(--pngx-toast-success);
background-color: var(--pngx-toast-success-bg);
border-color: color-mix(in srgb, var(--pngx-toast-success) 30%, transparent);
}
&.toast-error {
--pngx-toast-accent: var(--pngx-toast-error);
background-color: var(--pngx-toast-error-bg);
border-color: color-mix(in srgb, var(--pngx-toast-error) 35%, transparent);
}
::ng-deep .toast.error {
border-color: hsla(350, 79%, 40%, 0.4); // bg-danger
}
.toast-icon {
color: var(--pngx-toast-accent);
::ng-deep .toast.error .toast-body {
background-color: hsla(350, 79%, 40%, 0.8); // bg-danger
border-top-left-radius: inherit;
border-top-right-radius: inherit;
border-bottom-left-radius: inherit;
border-bottom-right-radius: inherit;
}
.toast-content {
flex: 1 1 auto;
min-width: 0;
}
.toast .btn-close {
font-size: .6rem;
margin-top: .3rem;
margin-left: .75rem !important;
opacity: .45;
}
.toast:hover .btn-close {
opacity: .85;
}
// compact rows in the notifications dropdown
.toast.toast-list-item {
width: 100%;
max-width: none;
background-color: var(--pngx-toast-row-bg);
border: 0;
box-shadow: none;
&:hover {
background-color: var(--pngx-toast-row-hover-bg);
}
&.toast-error {
background-color: var(--pngx-toast-error-bg);
}
::ng-deep .toast-body {
padding: .625rem .75rem;
}
.toast-time {
margin-top: .1rem;
}
// marks notifications that arrived since the dropdown was last opened
&.toast-new .toast-time::before {
content: '';
display: inline-block;
width: .45rem;
height: .45rem;
margin-right: .4rem;
vertical-align: .05rem;
border-radius: 50%;
background-color: var(--pngx-toast-info);
}
}
.toast-action {
--bs-btn-padding-y: .1rem;
--bs-btn-padding-x: .6rem;
--bs-btn-font-size: .8rem;
--bs-btn-font-weight: 600;
--bs-btn-color: var(--pngx-toast-accent);
--bs-btn-bg: color-mix(in srgb, var(--pngx-toast-accent) 14%, transparent);
--bs-btn-border-color: transparent;
--bs-btn-hover-color: var(--pngx-toast-accent);
--bs-btn-hover-bg: color-mix(in srgb, var(--pngx-toast-accent) 24%, transparent);
--bs-btn-hover-border-color: transparent;
--bs-btn-active-color: var(--pngx-toast-accent);
--bs-btn-active-bg: color-mix(in srgb, var(--pngx-toast-accent) 30%, transparent);
--bs-btn-active-border-color: transparent;
}
// countdown thin line
.progress {
height: 2px;
background-color: transparent;
::ng-deep .progress-bar {
background-color: var(--bs-secondary-color);
opacity: .5;
}
background-color: var(--pngx-primary);
opacity: .07;
}
@@ -1,11 +1,7 @@
import { DatePipe } from '@angular/common'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { Clipboard } from '@angular/cdk/clipboard'
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { ToastLevel } from 'src/app/services/toast.service'
import { ToastComponent } from './toast.component'
const toast1 = {
@@ -34,11 +30,6 @@ describe('ToastComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ToastComponent, NgxBootstrapIconsModule.pick(allIcons)],
providers: [
DatePipe,
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
}).compileComponents()
fixture = TestBed.createComponent(ToastComponent)
@@ -55,117 +46,11 @@ describe('ToastComponent', () => {
component.toast = toast2
fixture.detectChanges()
component.onShown(toast2)
jest.advanceTimersByTime(5000 + 150) // ticks every 150ms
expect(component.delayRemaining()).toEqual(0)
jest.useRealTimers()
})
it('should start the progress bar full before the countdown begins', () => {
component.toast = { content: 'Info', delay: 3000 }
fixture.detectChanges()
expect(component.delayRemaining()).toEqual(3000)
})
it('should hide the toast when the countdown ends', () => {
jest.useFakeTimers()
const toast = { content: 'Info', delay: 3000 }
component.toast = toast
fixture.detectChanges()
const hideSpy = jest.spyOn(component.ngbToast, 'hide')
component.onShown(toast)
jest.advanceTimersByTime(2850)
expect(hideSpy).not.toHaveBeenCalled()
jest.advanceTimersByTime(300)
expect(hideSpy).toHaveBeenCalled()
jest.useRealTimers()
})
it('should pause the countdown while hovered', () => {
jest.useFakeTimers()
const toast = { content: 'Info', delay: 3000 }
component.toast = toast
fixture.detectChanges()
const hideSpy = jest.spyOn(component.ngbToast, 'hide')
const el: HTMLElement = fixture.nativeElement.querySelector('ngb-toast')
component.onShown(toast)
jest.advanceTimersByTime(1500)
el.dispatchEvent(new MouseEvent('mouseenter'))
const remaining = component.delayRemaining()
jest.advanceTimersByTime(10000)
expect(component.delayRemaining()).toEqual(remaining)
expect(hideSpy).not.toHaveBeenCalled()
el.dispatchEvent(new MouseEvent('mouseleave'))
jest.advanceTimersByTime(remaining + 150)
expect(hideSpy).toHaveBeenCalled()
jest.useRealTimers()
})
it('should pause the countdown while focus is inside', () => {
jest.useFakeTimers()
const toast = { content: 'Info', delay: 3000 }
component.toast = toast
fixture.detectChanges()
const el: HTMLElement = fixture.nativeElement.querySelector('ngb-toast')
component.onShown(toast)
el.dispatchEvent(new FocusEvent('focusin'))
// mouse leaving doesn't resume while focus is still inside
el.dispatchEvent(new MouseEvent('mouseenter'))
el.dispatchEvent(new MouseEvent('mouseleave'))
jest.advanceTimersByTime(5000)
expect(component.delayRemaining()).toEqual(3000)
el.dispatchEvent(new FocusEvent('focusout'))
jest.advanceTimersByTime(3000)
expect(component.delayRemaining()).toEqual(0)
expect(component.toast.delayRemaining).toEqual(0)
jest.useRealTimers()
})
it('should not count down without autohide', () => {
jest.useFakeTimers()
const toast = { content: 'Info', delay: 3000 }
component.toast = toast
component.autohide = false
fixture.detectChanges()
component.onShown(toast)
jest.advanceTimersByTime(5000)
expect(component.delayRemaining()).toBeNull()
jest.useRealTimers()
})
it('should show an icon and colors for each level', () => {
component.toast = {
content: 'Saved',
delay: 5000,
level: ToastLevel.Success,
}
fixture.detectChanges()
const el: HTMLElement = fixture.nativeElement
expect(el.querySelector('i-bs[name="check-circle"]')).not.toBeNull()
expect(el.querySelector('ngb-toast').classList).not.toContain('toast-error')
fixture = TestBed.createComponent(ToastComponent)
fixture.componentInstance.toast = {
content: 'Failed',
delay: 5000,
level: ToastLevel.Error,
}
fixture.detectChanges()
const errorEl: HTMLElement = fixture.nativeElement
expect(
errorEl.querySelector('i-bs[name="exclamation-triangle"]')
).not.toBeNull()
expect(errorEl.querySelector('ngb-toast').classList).toContain(
'toast-error'
)
fixture = TestBed.createComponent(ToastComponent)
fixture.componentInstance.toast = { content: 'Started', delay: 5000 }
fixture.detectChanges()
expect(
fixture.nativeElement.querySelector('i-bs[name="info-circle"]')
).not.toBeNull()
})
it('should show an error if given with toast', () => {
component.toast = toast1
fixture.detectChanges()
@@ -203,70 +88,4 @@ describe('ToastComponent', () => {
component.getErrorText({ error: new Array(205).join('a') })
).toContain('...')
})
it('should emit actioned, not closed, when the action is clicked', () => {
const action = jest.fn()
component.toast = {
content: 'Moved',
delay: 5000,
action,
actionName: 'Undo',
}
fixture.detectChanges()
const closedSpy = jest.spyOn(component.closed, 'emit')
const actionedSpy = jest.spyOn(component.actioned, 'emit')
const button: HTMLButtonElement = [
...fixture.nativeElement.querySelectorAll('button'),
].find((b) => b.textContent.includes('Undo'))
button.click()
expect(actionedSpy).toHaveBeenCalled()
expect(closedSpy).not.toHaveBeenCalled()
expect(action).toHaveBeenCalled()
})
it('should show a relative timestamp in the notifications list', () => {
component.toast = { content: 'Saved', delay: 5000, timestamp: new Date() }
component.autohide = false
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('Just now')
})
it('should update the relative timestamp as time passes', () => {
jest.useFakeTimers()
jest.setSystemTime(new Date('2026-10-19T12:00:00Z'))
component.toast = { content: 'Saved', delay: 5000, timestamp: new Date() }
component.autohide = false
fixture.componentRef.setInput('now', new Date())
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('Just now')
jest.setSystemTime(new Date('2026-10-19T12:03:00Z'))
fixture.componentRef.setInput('now', new Date())
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('3 minutes ago')
jest.useRealTimers()
})
it('should show a compact action link in the notifications list', () => {
const action = jest.fn()
component.toast = {
content: 'Moved',
delay: 5000,
action,
actionName: 'Undo',
timestamp: new Date(),
}
component.autohide = false
fixture.detectChanges()
const link: HTMLButtonElement =
fixture.nativeElement.querySelector('.toast-action')
expect(link.textContent).toContain('Undo')
expect(
fixture.nativeElement.querySelector('.btn-outline-secondary')
).toBeNull()
const actionedSpy = jest.spyOn(component.actioned, 'emit')
link.click()
expect(actionedSpy).toHaveBeenCalled()
expect(action).toHaveBeenCalled()
})
})
@@ -1,26 +1,20 @@
import { Clipboard } from '@angular/cdk/clipboard'
import { DatePipe, DecimalPipe } from '@angular/common'
import { DecimalPipe } from '@angular/common'
import {
Component,
DestroyRef,
EventEmitter,
Input,
OnInit,
Output,
ViewChild,
inject,
signal,
} from '@angular/core'
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import {
NgbProgressbarModule,
NgbToast,
NgbToastModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Subscription, interval, takeWhile } from 'rxjs'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { Toast, ToastLevel } from 'src/app/services/toast.service'
import { interval, take } from 'rxjs'
import { Toast } from 'src/app/services/toast.service'
@Component({
selector: 'pngx-toast',
@@ -32,79 +26,32 @@ import { Toast, ToastLevel } from 'src/app/services/toast.service'
],
templateUrl: './toast.component.html',
styleUrl: './toast.component.scss',
providers: [DatePipe, CustomDatePipe],
})
export class ToastComponent implements OnInit {
export class ToastComponent {
private clipboard = inject(Clipboard)
private customDatePipe = inject(CustomDatePipe)
private destroyRef = inject(DestroyRef)
readonly copied = signal(false)
readonly ToastLevel = ToastLevel
readonly delayRemaining = signal<number>(null)
@ViewChild(NgbToast) ngbToast: NgbToast
private hovered = false
private focused = false
private countdown: Subscription
@Input() toast: Toast
@Input() autohide: boolean = true
/** Each new value re-renders the relative timestamp */
@Input() now: Date
/** Marks a notification that arrived since the dropdown was last opened */
@Input() isNew: boolean = false
@Output() hidden: EventEmitter<Toast> = new EventEmitter<Toast>()
@Output() closed: EventEmitter<Toast> = new EventEmitter<Toast>()
/** The toast's action button was clicked */
@Output() actioned: EventEmitter<Toast> = new EventEmitter<Toast>()
ngOnInit(): void {
if (this.autohide) this.delayRemaining.set(this.toast.delay)
}
get relativeTimestamp(): string {
return this.customDatePipe.transform(this.toast.timestamp, 'relative')
}
get paused(): boolean {
return this.hovered || this.focused
}
setHovered(hovered: boolean) {
this.hovered = hovered
}
setFocused(focused: boolean) {
this.focused = focused
}
onShown(toast: Toast) {
if (!this.autohide) return
const refreshInterval = 150
this.delayRemaining.set(toast.delay)
const delay = toast.delay - 500 // for fade animation
this.countdown?.unsubscribe()
this.countdown = interval(refreshInterval)
.pipe(
takeWhile(() => this.delayRemaining() > 0),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => {
if (this.paused) return
this.delayRemaining.update((remaining) =>
Math.max(0, remaining - refreshInterval)
interval(refreshInterval)
.pipe(take(Math.round(delay / refreshInterval)))
.subscribe((count) => {
toast.delayRemaining = Math.max(
0,
delay - refreshInterval * (count + 1)
)
if (this.delayRemaining() === 0) {
this.ngbToast?.hide()
}
})
}
@@ -1,3 +1,3 @@
@for (toast of toasts(); track toast.id) {
<pngx-toast [toast]="toast" [autohide]="true" (closed)="removePopup(toast)" (actioned)="closeToast(toast)" (hidden)="removePopup(toast)"></pngx-toast>
<pngx-toast [toast]="toast" [autohide]="true" (closed)="closeToast()"></pngx-toast>
}
@@ -1,11 +1,10 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { By } from '@angular/platform-browser'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { Subject } from 'rxjs'
import { Toast, ToastService } from 'src/app/services/toast.service'
import { MAX_VISIBLE_TOASTS, ToastsComponent } from './toasts.component'
import { ToastsComponent } from './toasts.component'
const toast = {
content: 'Error 2 content',
@@ -51,7 +50,7 @@ describe('ToastsComponent', () => {
it('should close toast', () => {
toastSubject.next(toast)
const closeToastSpy = jest.spyOn(toastService, 'closeToast')
component.closeToast(toast)
component.closeToast()
expect(component.toasts()).toEqual([])
expect(closeToastSpy).toHaveBeenCalledWith(toast)
})
@@ -60,59 +59,4 @@ describe('ToastsComponent', () => {
toastSubject.next(toast)
expect(component.toasts()).toEqual([toast])
})
it('should stack popups newest first, up to the maximum', () => {
const toasts: Toast[] = Array.from(
{ length: MAX_VISIBLE_TOASTS + 1 },
(_, i) => ({ id: `${i}`, content: `Toast ${i}`, delay: 5000 })
)
toasts.forEach((t) => toastSubject.next(t))
expect(component.toasts().map((t) => t.id)).toEqual(['3', '2', '1'])
// showing a visible toast again moves it to the top without duplicating
toastSubject.next(toasts[1])
expect(component.toasts().map((t) => t.id)).toEqual(['1', '3', '2'])
})
it('should remove a popup when it hides without closing it', () => {
const closeToastSpy = jest.spyOn(toastService, 'closeToast')
const first = { id: 'a', content: 'A', delay: 5000 }
const second = { id: 'b', content: 'B', delay: 5000 }
toastSubject.next(first)
toastSubject.next(second)
component.removePopup(first)
expect(component.toasts()).toEqual([second])
expect(closeToastSpy).not.toHaveBeenCalled()
})
it('should clear popups when toastService emits null', () => {
toastSubject.next(toast)
toastSubject.next(null)
expect(component.toasts()).toEqual([])
})
it('should render one toast component per popup', () => {
toastSubject.next({ id: 'a', content: 'A', delay: 5000 })
toastSubject.next({ id: 'b', content: 'B', delay: 5000 })
fixture.detectChanges()
expect(fixture.nativeElement.querySelectorAll('pngx-toast')).toHaveLength(2)
})
it('should only hide the popup on close, but clear it on action', () => {
const closeToastSpy = jest.spyOn(toastService, 'closeToast')
const first = { id: 'a', content: 'A', delay: 5000 }
const second = { id: 'b', content: 'B', delay: 5000 }
toastSubject.next(first)
toastSubject.next(second)
fixture.detectChanges()
const popups = fixture.debugElement.queryAll(By.css('pngx-toast'))
popups[1].triggerEventHandler('closed', first)
expect(closeToastSpy).not.toHaveBeenCalled()
expect(component.toasts()).toEqual([second])
popups[0].triggerEventHandler('actioned', second)
expect(closeToastSpy).toHaveBeenCalledWith(second)
expect(component.toasts()).toEqual([])
})
})
@@ -1,18 +1,14 @@
import { Component, inject, signal } from '@angular/core'
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import { Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import {
NgbAccordionModule,
NgbProgressbarModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { map, merge, Subject } from 'rxjs'
import { Toast, ToastService } from 'src/app/services/toast.service'
import { ToastComponent } from '../toast/toast.component'
/**
* Maximum number of popup toasts shown at once
*/
export const MAX_VISIBLE_TOASTS = 3
@Component({
selector: 'pngx-toasts',
templateUrl: './toasts.component.html',
@@ -27,31 +23,18 @@ export const MAX_VISIBLE_TOASTS = 3
export class ToastsComponent {
toastService = inject(ToastService)
readonly toasts = signal<Toast[]>([])
private readonly closedToast = new Subject<void>()
constructor() {
this.toastService.showToast
.pipe(takeUntilDestroyed())
.subscribe((toast) => {
if (!toast) {
this.toasts.set([])
return
}
this.toasts.update((toasts) =>
[toast, ...toasts.filter((t) => t.id !== toast.id)].slice(
0,
MAX_VISIBLE_TOASTS
)
)
})
}
readonly toasts = toSignal(
merge(
this.toastService.showToast.pipe(map((toast) => (toast ? [toast] : []))),
this.closedToast.pipe(map(() => [] as Toast[]))
),
{ initialValue: [] as Toast[] }
)
closeToast(toast: Toast) {
this.toastService.closeToast(toast)
this.removePopup(toast)
}
removePopup(toast: Toast) {
this.toasts.update((toasts) => toasts.filter((t) => t.id !== toast.id))
closeToast() {
this.toastService.closeToast(this.toasts()[0])
this.closedToast.next()
}
}
@@ -1,52 +0,0 @@
@if (canAddDocuments && getStatus().length > 0) {
<div class="fixed-bottom p-2 p-md-4 d-flex justify-content-end pe-none consumer-status-list" [ngClass]="slimSidebarEnabled ? 'col-slim' : 'offset-sidebar-expanded'">
<div class="col col-lg-4 col-xl-3 ps-0 pe-0 ps-lg-3 pe-lg-0 pe-auto overflow-y-auto">
<div class="card shadow-sm consumer-status-card">
<div class="card-body">
<div class="d-flex flex-wrap align-items-center column-gap-2">
<p class="m-0 small text-muted">{{getStatusSummary()}}</p>
<div class="d-flex align-items-center gap-2 ms-auto">
@if (getStatusCompleted().length > 0) {
<a class="btn-link small text-nowrap" (click)="dismissCompleted()" [routerLink]="[]">
<span class="me-1" i18n="This button dismisses all status messages about processed documents in the tray (failed and successful)">Dismiss completed</span>
<i-bs name="check2-all"></i-bs>
</a>
}
<button type="button" class="btn btn-sm btn-link p-0 text-muted" (click)="toggleCollapsed()"
[attr.aria-expanded]="!collapsed()" aria-label="Toggle document processing status" i18n-aria-label>
<i-bs [name]="collapsed() ? 'chevron-up' : 'chevron-down'"></i-bs>
</button>
</div>
</div>
@if (!collapsed()) {
@for (status of getStatus(); track status) {
<ng-container [ngTemplateOutlet]="consumerAlert" [ngTemplateOutletContext]="{ $implicit: status }"></ng-container>
}
}
</div>
</div>
</div>
</div>
}
<ng-template #consumerAlert let-status>
<ngb-alert type="secondary" class="mt-2 mb-0" [dismissible]="isFinished(status)" (closed)="dismiss(status)">
<h6 class="alert-heading">{{status.filename}}</h6>
@if (!isFinished(status) || (isFinished(status) && !status.documentId)) {
<p class="mb-0 pb-1">{{status.message}}</p>
}
<ngb-progressbar [value]="status.getProgress()" [max]="1" [type]="getStatusColor(status)"></ngb-progressbar>
<div *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
@if (isFinished(status)) {
<div>
@if (status.documentId) {
<a class="btn btn-sm btn-outline-primary btn-open" [routerLink]="['/documents', status.documentId]" (click)="dismiss(status)">
<small i18n>Open document</small>
<i-bs name="arrow-right-short"></i-bs>
</a>
}
</div>
}
</div>
</ngb-alert>
</ng-template>
@@ -1,36 +0,0 @@
:host ::ng-deep i-bs svg {
margin-top: -3px;
}
.alert-heading {
font-size: 80%;
font-weight: bold;
}
.btn-open {
line-height: 1;
i-bs {
margin-top: -1px;
}
}
.consumer-status-card {
background-color: rgba(var(--bs-body-bg-rgb), .95) !important;
}
.consumer-status-list {
max-height: 60vh;
z-index: 1030;
}
.progress {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
height: auto;
mix-blend-mode: soft-light;
pointer-events: none;
}
@@ -1,206 +0,0 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { RouterTestingModule } from '@angular/router/testing'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { routes } from 'src/app/app-routing.module'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { PermissionsService } from 'src/app/services/permissions.service'
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
import {
FileStatus,
FileStatusPhase,
WebsocketStatusService,
} from 'src/app/services/websocket-status.service'
import { UploadStatusTrayComponent } from './upload-status-tray.component'
const FAILED_STATUSES = [new FileStatus()]
const WORKING_STATUSES = [new FileStatus(), new FileStatus()]
const STARTED_STATUSES = [new FileStatus(), new FileStatus(), new FileStatus()]
const SUCCESS_STATUSES = [
new FileStatus(),
new FileStatus(),
new FileStatus(),
new FileStatus(),
]
const DEFAULT_STATUSES = [
new FileStatus(),
new FileStatus(),
new FileStatus(),
new FileStatus(),
new FileStatus(),
new FileStatus(),
]
describe('UploadStatusTrayComponent', () => {
let component: UploadStatusTrayComponent
let fixture: ComponentFixture<UploadStatusTrayComponent>
let websocketStatusService: WebsocketStatusService
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [
RouterTestingModule.withRoutes(routes),
NgxBootstrapIconsModule.pick(allIcons),
UploadStatusTrayComponent,
],
providers: [
PermissionsGuard,
{
provide: PermissionsService,
useValue: {
currentUserCan: () => true,
},
},
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
}).compileComponents()
websocketStatusService = TestBed.inject(WebsocketStatusService)
fixture = TestBed.createComponent(UploadStatusTrayComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should not render without statuses', () => {
expect(
fixture.nativeElement.querySelector('.consumer-status-card')
).toBeNull()
})
it('should render statuses and support collapsing', () => {
const status = new FileStatus()
status.filename = 'invoice.pdf'
status.phase = FileStatusPhase.WORKING
jest
.spyOn(websocketStatusService, 'getConsumerStatus')
.mockReturnValue([status])
// a mocked return value doesn't mark the zoneless view dirty, so re-create it
fixture = TestBed.createComponent(UploadStatusTrayComponent)
component = fixture.componentInstance
fixture.detectChanges()
const el: HTMLElement = fixture.nativeElement
expect(el.querySelector('.consumer-status-card')).not.toBeNull()
expect(el.textContent).toContain('invoice.pdf')
component.toggleCollapsed()
fixture.detectChanges()
expect(el.querySelector('.consumer-status-card')).not.toBeNull()
expect(el.textContent).not.toContain('invoice.pdf')
component.toggleCollapsed()
fixture.detectChanges()
expect(el.textContent).toContain('invoice.pdf')
})
it('should expand again once all statuses are dismissed', () => {
const status = websocketStatusService.newFileUpload('invoice.pdf')
fixture.detectChanges()
component.toggleCollapsed()
expect(component.collapsed()).toBe(true)
websocketStatusService.dismiss(status)
fixture.detectChanges()
expect(component.collapsed()).toBe(false)
})
it('should expand on request', () => {
component.toggleCollapsed()
expect(component.collapsed()).toBe(true)
component.expand()
expect(component.collapsed()).toBe(false)
})
it('should expand when new uploads start', () => {
const uploadDocumentsService = TestBed.inject(UploadDocumentsService)
jest
.spyOn(uploadDocumentsService, 'uploadFile')
.mockImplementation(() => {})
websocketStatusService.newFileUpload('invoice.pdf')
fixture.detectChanges()
component.toggleCollapsed()
expect(component.collapsed()).toBe(true)
uploadDocumentsService.uploadFiles([new File(['one'], 'one.pdf')])
expect(component.collapsed()).toBe(false)
})
it('should generate stats summary', () => {
mockConsumerStatuses(websocketStatusService)
expect(component.getStatusSummary()).toEqual(
'Processing: 6, Failed: 1, Added: 4'
)
})
it('should report an upload progress summary', () => {
mockConsumerStatuses(websocketStatusService)
expect(component.getTotalUploadProgress()).toEqual(0.75)
})
it('should change color by status phase', () => {
const processingStatus = new FileStatus()
processingStatus.phase = FileStatusPhase.WORKING
expect(component.getStatusColor(processingStatus)).toEqual('primary')
processingStatus.phase = FileStatusPhase.UPLOADING
expect(component.getStatusColor(processingStatus)).toEqual('primary')
const failedStatus = new FileStatus()
failedStatus.phase = FileStatusPhase.FAILED
expect(component.getStatusColor(failedStatus)).toEqual('danger')
const successStatus = new FileStatus()
successStatus.phase = FileStatusPhase.SUCCESS
expect(component.getStatusColor(successStatus)).toEqual('success')
})
it('should allow dismissing an alert', () => {
const dismissSpy = jest.spyOn(websocketStatusService, 'dismiss')
component.dismiss(new FileStatus())
expect(dismissSpy).toHaveBeenCalled()
})
it('should allow dismissing completed alerts', () => {
jest.useFakeTimers()
mockConsumerStatuses(websocketStatusService)
fixture.detectChanges()
jest
.spyOn(component, 'getStatusCompleted')
.mockImplementation(() => SUCCESS_STATUSES)
const dismissSpy = jest.spyOn(websocketStatusService, 'dismiss')
component.dismissCompleted()
jest.advanceTimersByTime(1000)
fixture.detectChanges()
expect(dismissSpy).toHaveBeenCalledTimes(4)
jest.useRealTimers()
})
})
function mockConsumerStatuses(consumerStatusService) {
const partialUpload1 = new FileStatus()
partialUpload1.currentPhaseProgress = 50
partialUpload1.currentPhaseMaxProgress = 50
const partialUpload2 = new FileStatus()
partialUpload2.currentPhaseProgress = 25
partialUpload2.currentPhaseMaxProgress = 50
jest
.spyOn(consumerStatusService, 'getConsumerStatus')
.mockImplementation((phase) => {
switch (phase) {
case FileStatusPhase.FAILED:
return FAILED_STATUSES
case FileStatusPhase.WORKING:
return WORKING_STATUSES
case FileStatusPhase.STARTED:
return STARTED_STATUSES
case FileStatusPhase.SUCCESS:
return SUCCESS_STATUSES
case FileStatusPhase.UPLOADING:
return [partialUpload1, partialUpload2]
default:
return DEFAULT_STATUSES
}
})
jest
.spyOn(consumerStatusService, 'getConsumerStatusNotCompleted')
.mockImplementation(() => {
return DEFAULT_STATUSES
})
}
@@ -1,165 +0,0 @@
import { NgClass, NgTemplateOutlet } from '@angular/common'
import { Component, effect, inject, signal } from '@angular/core'
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import { RouterModule } from '@angular/router'
import {
NgbAlertModule,
NgbProgressbarModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import {
PermissionAction,
PermissionsService,
PermissionType,
} from 'src/app/services/permissions.service'
import { SettingsService } from 'src/app/services/settings.service'
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
import {
FileStatus,
FileStatusPhase,
WebsocketStatusService,
} from 'src/app/services/websocket-status.service'
/** Floating list of document consumption statuses, shown on every page */
@Component({
selector: 'pngx-upload-status-tray',
templateUrl: './upload-status-tray.component.html',
styleUrls: ['./upload-status-tray.component.scss'],
imports: [
IfPermissionsDirective,
NgClass,
NgTemplateOutlet,
RouterModule,
NgbAlertModule,
NgbProgressbarModule,
NgxBootstrapIconsModule,
],
})
export class UploadStatusTrayComponent extends ComponentWithPermissions {
private websocketStatusService = inject(WebsocketStatusService)
private settingsService = inject(SettingsService)
private permissionsService = inject(PermissionsService)
private uploadDocumentsService = inject(UploadDocumentsService)
readonly collapsed = signal(false)
constructor() {
super()
// start expanded again for the next uploads once everything is dismissed
effect(() => {
if (this.getStatus().length === 0) this.collapsed.set(false)
})
this.uploadDocumentsService
.onUploadsStarted()
.pipe(takeUntilDestroyed())
.subscribe(() => this.expand())
}
get canAddDocuments(): boolean {
return this.permissionsService.currentUserCan(
PermissionAction.Add,
PermissionType.Document
)
}
getStatus() {
return this.websocketStatusService.getConsumerStatus()
}
getStatusSummary() {
let strings = []
let countUploadingAndProcessing =
this.websocketStatusService.getConsumerStatusNotCompleted().length
let countFailed = this.getStatusFailed().length
let countSuccess = this.getStatusSuccess().length
if (countUploadingAndProcessing > 0) {
strings.push($localize`Processing: ${countUploadingAndProcessing}`)
}
if (countFailed > 0) {
strings.push($localize`Failed: ${countFailed}`)
}
if (countSuccess > 0) {
strings.push($localize`Added: ${countSuccess}`)
}
return strings.join(
$localize`:this string is used to separate processing, failed and added on the file upload widget:, `
)
}
getStatusUploading() {
return this.websocketStatusService.getConsumerStatus(
FileStatusPhase.UPLOADING
)
}
getStatusFailed() {
return this.websocketStatusService.getConsumerStatus(FileStatusPhase.FAILED)
}
getStatusSuccess() {
return this.websocketStatusService.getConsumerStatus(
FileStatusPhase.SUCCESS
)
}
getStatusCompleted() {
return this.websocketStatusService.getConsumerStatusCompleted()
}
getTotalUploadProgress() {
let current = 0
let max = 0
this.getStatusUploading().forEach((status) => {
current += status.currentPhaseProgress
max += status.currentPhaseMaxProgress
})
return current / Math.max(max, 1)
}
isFinished(status: FileStatus) {
return (
status.phase == FileStatusPhase.FAILED ||
status.phase == FileStatusPhase.SUCCESS
)
}
getStatusColor(status: FileStatus) {
switch (status.phase) {
case FileStatusPhase.UPLOADING:
case FileStatusPhase.STARTED:
case FileStatusPhase.WORKING:
return 'primary'
case FileStatusPhase.FAILED:
return 'danger'
case FileStatusPhase.SUCCESS:
return 'success'
}
}
toggleCollapsed() {
this.collapsed.update((collapsed) => !collapsed)
}
expand() {
this.collapsed.set(false)
}
dismiss(status: FileStatus) {
this.websocketStatusService.dismiss(status)
}
dismissCompleted() {
this.getStatusCompleted().forEach((status) =>
this.websocketStatusService.dismiss(status)
)
}
get slimSidebarEnabled(): boolean {
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
}
}
@@ -173,7 +173,7 @@ describe('DashboardComponent', () => {
it('should update saved view sorting on drag + drop, show info', () => {
const settingsSpy = jest.spyOn(settingsService, 'updateDashboardViewsSort')
const toastSpy = jest.spyOn(toastService, 'showSuccess')
const toastSpy = jest.spyOn(toastService, 'showInfo')
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
component.onDrop({ previousIndex: 0, currentIndex: 1 } as CdkDragDrop<
SavedView[]
@@ -87,7 +87,7 @@ export class DashboardComponent extends ComponentWithPermissions {
.updateDashboardViewsSort(this.dashboardViews())
.subscribe({
next: () => {
this.toastService.showSuccess($localize`Dashboard updated`)
this.toastService.showInfo($localize`Dashboard updated`)
},
error: (e) => {
this.toastService.showError($localize`Error updating dashboard`, e)
@@ -8,5 +8,59 @@
</button>
<input type="file" class="visually-hidden" (change)="onFileSelected($event)" multiple #fileUpload>
</form>
@if (getStatus().length > 0) {
<div class="fixed-bottom p-2 p-md-4 d-flex justify-content-end pe-none consumer-status-list" [ngClass]="slimSidebarEnabled ? 'col-slim' : 'offset-sidebar-expanded'">
<div class="col col-lg-4 col-xl-3 ps-0 pe-0 ps-lg-3 pe-lg-0 pe-auto overflow-y-scroll">
<div class="card shadow-sm consumer-status-card">
<div class="card-body">
<div class="row">
<div class="col d-flex justify-content-between align-items-center">
@if (getStatus().length > 0) {
<p class="m-0 small text-muted">{{getStatusSummary()}}</p>
}
@if (getStatusCompleted().length > 0) {
<a class="btn-link" (click)="dismissCompleted()" [routerLink]="[]" >
<span class="me-1" i18n="This button dismisses all status messages about processed documents on the dashboard (failed and successful)">Dismiss completed</span>
<i-bs name="check2-all"></i-bs>
</a>
}
</div>
</div>
<div class="row">
<div class="col">
@for (status of getStatus(); track status) {
<div>
<ng-container [ngTemplateOutlet]="consumerAlert" [ngTemplateOutletContext]="{ $implicit: status }"></ng-container>
</div>
}
</div>
</div>
</div>
</div>
</div>
</div>
}
</div>
</pngx-widget-frame>
<ng-template #consumerAlert let-status>
<ngb-alert type="secondary" class="mt-2 mb-0" [dismissible]="isFinished(status)" (closed)="dismiss(status)">
<h6 class="alert-heading">{{status.filename}}</h6>
@if (!isFinished(status) || (isFinished(status) && !status.documentId)) {
<p class="mb-0 pb-1">{{status.message}}</p>
}
<ngb-progressbar [value]="status.getProgress()" [max]="1" [type]="getStatusColor(status)"></ngb-progressbar>
<div *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
@if (isFinished(status)) {
<div>
@if (status.documentId) {
<a class="btn btn-sm btn-outline-primary btn-open" [routerLink]="['/documents', status.documentId]" (click)="dismiss(status)">
<small i18n>Open document</small>
<i-bs name="arrow-right-short"></i-bs>
</a>
}
</div>
}
</div>
</ngb-alert>
</ng-template>
@@ -20,6 +20,48 @@
font-size: 0.75rem;
}
.alert-heading {
font-size: 80%;
font-weight: bold;
}
.alerts-hidden {
.btn {
line-height: 1;
}
}
.btn-open {
line-height: 1;
i-bs {
margin-top: -1px;
}
}
.progress {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
height: auto;
mix-blend-mode: soft-light;
pointer-events: none;
}
::ng-deep .ngx-file-drop__drop-zone--over {
background-color: var(--pngx-primary-faded) !important;
}
.consumer-status-card {
background-color: rgba(var(--bs-body-bg-rgb), .95) !important;
}
.consumer-status-list {
max-height: calc(100vh - 312px); // e.g. below the upload button, mobile
@media screen and (min-width: 768px) {
max-height: calc(100vh - 208px); // e.g. below the upload button
}
}
@@ -9,11 +9,35 @@ import { routes } from 'src/app/app-routing.module'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { PermissionsService } from 'src/app/services/permissions.service'
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
import {
FileStatus,
FileStatusPhase,
WebsocketStatusService,
} from 'src/app/services/websocket-status.service'
import { UploadFileWidgetComponent } from './upload-file-widget.component'
const FAILED_STATUSES = [new FileStatus()]
const WORKING_STATUSES = [new FileStatus(), new FileStatus()]
const STARTED_STATUSES = [new FileStatus(), new FileStatus(), new FileStatus()]
const SUCCESS_STATUSES = [
new FileStatus(),
new FileStatus(),
new FileStatus(),
new FileStatus(),
]
const DEFAULT_STATUSES = [
new FileStatus(),
new FileStatus(),
new FileStatus(),
new FileStatus(),
new FileStatus(),
new FileStatus(),
]
describe('UploadFileWidgetComponent', () => {
let component: UploadFileWidgetComponent
let fixture: ComponentFixture<UploadFileWidgetComponent>
let websocketStatusService: WebsocketStatusService
let uploadDocumentsService: UploadDocumentsService
beforeEach(async () => {
@@ -37,6 +61,7 @@ describe('UploadFileWidgetComponent', () => {
],
}).compileComponents()
websocketStatusService = TestBed.inject(WebsocketStatusService)
uploadDocumentsService = TestBed.inject(UploadDocumentsService)
fixture = TestBed.createComponent(UploadFileWidgetComponent)
component = fixture.componentInstance
@@ -60,10 +85,93 @@ describe('UploadFileWidgetComponent', () => {
'file.pdf'
)
const fileInput = fixture.debugElement.query(By.css('input'))
jest
.spyOn(fileInput.nativeElement, 'files', 'get')
.mockReturnValue([file] as any)
jest.spyOn(fileInput.nativeElement, 'files', 'get').mockReturnValue({
item: () => file,
length: 1,
[Symbol.iterator]: () => ({
next: () => ({ done: false, value: file }),
}),
} as any)
fileInput.nativeElement.dispatchEvent(new Event('change'))
expect(uploadSpy).toHaveBeenCalledWith(file)
expect(uploadSpy).toHaveBeenCalled()
})
it('should generate stats summary', () => {
mockConsumerStatuses(websocketStatusService)
expect(component.getStatusSummary()).toEqual(
'Processing: 6, Failed: 1, Added: 4'
)
})
it('should report an upload progress summary', () => {
mockConsumerStatuses(websocketStatusService)
expect(component.getTotalUploadProgress()).toEqual(0.75)
})
it('should change color by status phase', () => {
const processingStatus = new FileStatus()
processingStatus.phase = FileStatusPhase.WORKING
expect(component.getStatusColor(processingStatus)).toEqual('primary')
processingStatus.phase = FileStatusPhase.UPLOADING
expect(component.getStatusColor(processingStatus)).toEqual('primary')
const failedStatus = new FileStatus()
failedStatus.phase = FileStatusPhase.FAILED
expect(component.getStatusColor(failedStatus)).toEqual('danger')
const successStatus = new FileStatus()
successStatus.phase = FileStatusPhase.SUCCESS
expect(component.getStatusColor(successStatus)).toEqual('success')
})
it('should allow dismissing an alert', () => {
const dismissSpy = jest.spyOn(websocketStatusService, 'dismiss')
component.dismiss(new FileStatus())
expect(dismissSpy).toHaveBeenCalled()
})
it('should allow dismissing completed alerts', () => {
jest.useFakeTimers()
mockConsumerStatuses(websocketStatusService)
fixture.detectChanges()
jest
.spyOn(component, 'getStatusCompleted')
.mockImplementation(() => SUCCESS_STATUSES)
const dismissSpy = jest.spyOn(websocketStatusService, 'dismiss')
component.dismissCompleted()
jest.advanceTimersByTime(1000)
fixture.detectChanges()
expect(dismissSpy).toHaveBeenCalledTimes(4)
jest.useRealTimers()
})
})
function mockConsumerStatuses(consumerStatusService) {
const partialUpload1 = new FileStatus()
partialUpload1.currentPhaseProgress = 50
partialUpload1.currentPhaseMaxProgress = 50
const partialUpload2 = new FileStatus()
partialUpload2.currentPhaseProgress = 25
partialUpload2.currentPhaseMaxProgress = 50
jest
.spyOn(consumerStatusService, 'getConsumerStatus')
.mockImplementation((phase) => {
switch (phase) {
case FileStatusPhase.FAILED:
return FAILED_STATUSES
case FileStatusPhase.WORKING:
return WORKING_STATUSES
case FileStatusPhase.STARTED:
return STARTED_STATUSES
case FileStatusPhase.SUCCESS:
return SUCCESS_STATUSES
case FileStatusPhase.UPLOADING:
return [partialUpload1, partialUpload2]
default:
return DEFAULT_STATUSES
}
})
jest
.spyOn(consumerStatusService, 'getConsumerStatusNotCompleted')
.mockImplementation(() => {
return DEFAULT_STATUSES
})
}
@@ -1,9 +1,23 @@
import { Component, inject } from '@angular/core'
import { NgClass, NgTemplateOutlet } from '@angular/common'
import { Component, QueryList, ViewChildren, inject } from '@angular/core'
import { RouterModule } from '@angular/router'
import {
NgbAlert,
NgbAlertModule,
NgbProgressbarModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { SettingsService } from 'src/app/services/settings.service'
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
import {
FileStatus,
FileStatusPhase,
WebsocketStatusService,
} from 'src/app/services/websocket-status.service'
import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
@Component({
@@ -13,16 +27,117 @@ import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
imports: [
WidgetFrameComponent,
IfPermissionsDirective,
NgClass,
NgTemplateOutlet,
RouterModule,
NgbAlertModule,
NgbProgressbarModule,
NgxBootstrapIconsModule,
TourNgBootstrap,
],
})
export class UploadFileWidgetComponent extends ComponentWithPermissions {
private websocketStatusService = inject(WebsocketStatusService)
private uploadDocumentsService = inject(UploadDocumentsService)
settingsService = inject(SettingsService)
@ViewChildren(NgbAlert) alerts: QueryList<NgbAlert>
getStatus() {
return this.websocketStatusService.getConsumerStatus()
}
getStatusSummary() {
let strings = []
let countUploadingAndProcessing =
this.websocketStatusService.getConsumerStatusNotCompleted().length
let countFailed = this.getStatusFailed().length
let countSuccess = this.getStatusSuccess().length
if (countUploadingAndProcessing > 0) {
strings.push($localize`Processing: ${countUploadingAndProcessing}`)
}
if (countFailed > 0) {
strings.push($localize`Failed: ${countFailed}`)
}
if (countSuccess > 0) {
strings.push($localize`Added: ${countSuccess}`)
}
return strings.join(
$localize`:this string is used to separate processing, failed and added on the file upload widget:, `
)
}
getStatusUploading() {
return this.websocketStatusService.getConsumerStatus(
FileStatusPhase.UPLOADING
)
}
getStatusFailed() {
return this.websocketStatusService.getConsumerStatus(FileStatusPhase.FAILED)
}
getStatusSuccess() {
return this.websocketStatusService.getConsumerStatus(
FileStatusPhase.SUCCESS
)
}
getStatusCompleted() {
return this.websocketStatusService.getConsumerStatusCompleted()
}
getTotalUploadProgress() {
let current = 0
let max = 0
this.getStatusUploading().forEach((status) => {
current += status.currentPhaseProgress
max += status.currentPhaseMaxProgress
})
return current / Math.max(max, 1)
}
isFinished(status: FileStatus) {
return (
status.phase == FileStatusPhase.FAILED ||
status.phase == FileStatusPhase.SUCCESS
)
}
getStatusColor(status: FileStatus) {
switch (status.phase) {
case FileStatusPhase.UPLOADING:
case FileStatusPhase.STARTED:
case FileStatusPhase.WORKING:
return 'primary'
case FileStatusPhase.FAILED:
return 'danger'
case FileStatusPhase.SUCCESS:
return 'success'
}
}
dismiss(status: FileStatus) {
this.websocketStatusService.dismiss(status)
}
dismissCompleted() {
this.getStatusCompleted().forEach((status) =>
this.websocketStatusService.dismiss(status)
)
}
public onFileSelected(event: Event) {
const input = event.target as HTMLInputElement
this.uploadDocumentsService.uploadFiles(Array.from(input.files ?? []))
input.value = ''
const files = (event.target as HTMLInputElement).files
for (let i = 0; i < files?.length; i++) {
const file = files.item(i)
file && this.uploadDocumentsService.uploadFile(file)
}
}
get slimSidebarEnabled(): boolean {
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
}
}
@@ -1,4 +1,25 @@
<pngx-page-header [title]="title()" [id]="documentId()">
@if (archiveContentRenderType === ContentRenderType.PDF && !useNativePdfViewer) {
@if (previewNumPages()) {
<div class="input-group input-group-sm ms-2 d-none d-md-flex">
<div class="input-group-text" i18n>Page</div>
<input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewNumPages()" [ngModel]="previewCurrentPage()" (ngModelChange)="previewCurrentPage.set($event)" />
<div class="input-group-text" i18n>of {{previewNumPages()}}</div>
</div>
}
<div class="input-group input-group-sm me-md-5 d-none d-md-flex">
<button class="btn btn-outline-secondary" (click)="decreaseZoom()" i18n>-</button>
<select class="form-select" (change)="setZoom($event.target.value)" [ngModel]="currentZoom">
@for (setting of zoomSettings; track setting) {
<option [value]="setting">
{{ getZoomSettingTitle(setting) }}
</option>
}
</select>
<button class="btn btn-outline-secondary" (click)="increaseZoom()" i18n>+</button>
</div>
}
<button type="button" class="btn btn-sm btn-outline-danger me-md-4" (click)="delete()" [disabled]="!userIsOwner" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }" aria-label="Delete" i18n-aria-label>
<i-bs width="1.2em" height="1.2em" name="trash"></i-bs><span class="d-none d-lg-inline ps-1" i18n>Delete</span>
</button>
@@ -13,7 +34,7 @@
(versionsUpdated)="onVersionsUpdated($event)"
/>
<div class="btn-group ms-4">
<div class="btn-group">
<button (click)="download()" class="btn btn-sm btn-outline-primary" [disabled]="downloading()" aria-label="Download" i18n-aria-label>
@if (downloading()) {
<div class="spinner-border spinner-border-sm" role="status"></div>
@@ -46,24 +67,24 @@
</button>
<div ngbDropdownMenu aria-labelledby="actionsDropdown" class="shadow">
<button ngbDropdownItem (click)="reprocess()" [disabled]="!userCanEdit || !userIsOwner">
<i-bs width="1em" height="1em" name="arrow-counterclockwise" class="me-2"></i-bs><span i18n>Reprocess</span>
<i-bs width="1em" height="1em" name="arrow-counterclockwise" class="me-1"></i-bs><span i18n>Reprocess</span>
</button>
<button ngbDropdownItem (click)="printDocument()" [hidden]="useNativePdfViewer || isMobile">
<i-bs width="1em" height="1em" name="printer" class="me-2"></i-bs><span i18n>Print</span>
<i-bs width="1em" height="1em" name="printer" class="me-1"></i-bs><span i18n>Print</span>
</button>
<button ngbDropdownItem (click)="moreLike()">
<i-bs width="1em" height="1em" name="diagram-3" class="me-2"></i-bs><span i18n>More like this</span>
<i-bs width="1em" height="1em" name="diagram-3" class="me-1"></i-bs><span i18n>More like this</span>
</button>
<button ngbDropdownItem (click)="editPdf()" [disabled]="!userIsOwner || !userCanEdit || originalContentRenderType !== ContentRenderType.PDF">
<i-bs name="pencil" class="me-2"></i-bs><ng-container i18n>PDF Editor</ng-container>
<i-bs name="pencil" class="me-1"></i-bs><ng-container i18n>PDF Editor</ng-container>
</button>
@if (userIsOwner && (requiresPassword || password)) {
<button ngbDropdownItem (click)="removePassword()" [disabled]="!password">
<i-bs name="unlock" class="me-2"></i-bs><ng-container i18n>Remove Password</ng-container>
<i-bs name="unlock" class="me-1"></i-bs><ng-container i18n>Remove Password</ng-container>
</button>
}
</div>
@@ -73,13 +94,13 @@
<button class="btn btn-sm btn-outline-primary" id="sendDropdown" ngbDropdownToggle aria-label="Send" i18n-aria-label>
<i-bs name="send"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Send</ng-container></div>
</button>
<div ngbDropdownMenu aria-labelledby="sendDropdown" class="shadow">
<div ngbDropdownMenu aria-labelledby="actionsDropdown" class="shadow">
<button ngbDropdownItem (click)="openShareLinks()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.ShareLink }">
<i-bs name="link" class="me-2"></i-bs><span i18n>Share Links</span>
<i-bs name="link" class="me-1"></i-bs><span i18n>Share Links</span>
</button>
@if (emailEnabled) {
<button ngbDropdownItem (click)="openEmailDocument()">
<i-bs name="envelope" class="me-2"></i-bs><span i18n>Email</span>
<i-bs name="envelope" class="me-1"></i-bs><span i18n>Email</span>
</button>
}
</div>
@@ -92,8 +113,8 @@
<form [formGroup]='documentForm' (ngSubmit)="save()">
<div class="btn-toolbar mb-1 pb-3 gap-2 row-gap-2 border-bottom">
<div class="btn-group">
<div class="btn-toolbar justify-content-end mb-1 pb-3 gap-2 row-gap-2 border-bottom">
<div class="btn-group me-auto">
<button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Close" (click)="close()">
<i-bs width="1.2em" height="1.2em" name="x"></i-bs>
</button>
@@ -106,30 +127,43 @@
</div>
<ng-container *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
<div class="btn-group ms-3">
<pngx-suggestions-dropdown *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }"
[disabled]="!userCanEdit || suggestionsLoading()"
[loading]="suggestionsLoading()"
[suggestions]="suggestions()"
[aiEnabled]="aiEnabled"
[source]="suggestionSource"
[defaultSource]="defaultSuggestionSource"
[fetchedSources]="fetchedSuggestionSources()"
[appliedTags]="documentForm.value.tags"
[appliedCorrespondent]="documentForm.value.correspondent"
[appliedDocumentType]="documentForm.value.document_type"
[appliedStoragePath]="documentForm.value.storage_path"
[appliedTitle]="documentForm.value.title"
[appliedCreated]="documentForm.value.created"
(getSuggestions)="getSuggestions($event)"
(sourceChange)="suggestionSourceOverride.set($event)"
(addTag)="createTag($event)"
(addDocumentType)="createDocumentType($event)"
(addCorrespondent)="createCorrespondent($event)">
</pngx-suggestions-dropdown>
<div class="d-flex gap-2">
<div class="btn-group">
<pngx-suggestions-dropdown *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }"
[disabled]="!userCanEdit || suggestionsLoading()"
[loading]="suggestionsLoading()"
[suggestions]="suggestions()"
[aiEnabled]="aiEnabled"
[source]="suggestionSource"
[defaultSource]="defaultSuggestionSource"
[fetchedSources]="fetchedSuggestionSources()"
[appliedTags]="documentForm.value.tags"
[appliedCorrespondent]="documentForm.value.correspondent"
[appliedDocumentType]="documentForm.value.document_type"
[appliedStoragePath]="documentForm.value.storage_path"
[appliedTitle]="documentForm.value.title"
[appliedCreated]="documentForm.value.created"
(getSuggestions)="getSuggestions($event)"
(sourceChange)="suggestionSourceOverride.set($event)"
(addTag)="createTag($event)"
(addDocumentType)="createDocumentType($event)"
(addCorrespondent)="createCorrespondent($event)">
</pngx-suggestions-dropdown>
</div>
<div class="btn-group">
<pngx-custom-fields-dropdown
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }"
[documentId]="documentId()"
[disabled]="!userCanEdit"
[existingFields]="document()?.custom_fields"
(created)="refreshCustomFields()"
(added)="addField($event)">
</pngx-custom-fields-dropdown>
</div>
</div>
<div class="ms-auto">
<div class="ps-3">
<ng-container *ngTemplateOutlet="saveButtons"></ng-container>
</div>
</ng-container>
@@ -256,20 +290,8 @@
}
</div>
<div class="d-flex border-top pt-3">
<ng-container *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
<pngx-custom-fields-dropdown
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }"
[documentId]="documentId()"
[disabled]="!userCanEdit"
[existingFields]="document()?.custom_fields"
(created)="refreshCustomFields()"
(added)="addField($event)">
</pngx-custom-fields-dropdown>
</ng-container>
<div class="ms-auto">
<ng-container *ngTemplateOutlet="saveButtons"></ng-container>
</div>
<div class="d-flex justify-content-end border-top pt-3">
<ng-container *ngTemplateOutlet="saveButtons"></ng-container>
</div>
</ng-template>
</li>
@@ -306,9 +328,9 @@
<td i18n>Original filename</td>
<td>{{metadata()?.original_filename}}</td>
</tr>
<tr class="align-middle">
<tr>
<td i18n>Original SHA256 checksum</td>
<td><span class="badge bg-light font-monospace text-wrap text-break text-start">{{metadata()?.original_checksum}}</span></td>
<td>{{metadata()?.original_checksum}}</td>
</tr>
<tr>
<td i18n>Original file size</td>
@@ -319,9 +341,9 @@
<td>{{metadata()?.original_mime_type}}</td>
</tr>
@if (metadata()?.has_archive_version) {
<tr class="align-middle">
<tr>
<td i18n>Archive SHA256 checksum</td>
<td><span class="badge bg-light font-monospace text-wrap text-break text-start">{{metadata()?.archive_checksum}}</span></td>
<td>{{metadata()?.archive_checksum}}</td>
</tr>
}
@if (metadata()?.has_archive_version) {
@@ -386,7 +408,7 @@
<a ngbNavLink i18n>Permissions</a>
<ng-template ngbNavContent>
<div class="mb-3">
<pngx-permissions-form title="" [users]="users()" formControlName="permissions_form"></pngx-permissions-form>
<pngx-permissions-form [users]="users()" formControlName="permissions_form"></pngx-permissions-form>
</div>
</ng-template>
</li>
@@ -477,29 +499,6 @@
(loadError)="onError($event)"
(afterLoadComplete)="pdfPreviewLoaded($event)">
</pngx-pdf-viewer>
<div class="position-absolute bottom-0 start-50 translate-middle-x z-1 mb-3 d-flex gap-2 p-1 bg-body border rounded shadow">
@if (previewNumPages()) {
<div class="input-group input-group-sm w-auto flex-nowrap">
<input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewNumPages()" [ngModelOptions]="{standalone: true}" [ngModel]="previewCurrentPage()" (ngModelChange)="previewCurrentPage.set($event)" aria-label="Page" i18n-aria-label />
<div class="input-group-text" i18n>of {{previewNumPages()}}</div>
</div>
}
<div class="input-group input-group-sm w-auto flex-nowrap">
<button class="btn btn-outline-secondary" (click)="decreaseZoom()" aria-label="Zoom out" i18n-aria-label>
<i-bs width="1em" height="1em" name="dash"></i-bs>
</button>
<select class="form-select" (change)="setZoom($event.target.value)" [ngModelOptions]="{standalone: true}" [ngModel]="currentZoom" aria-label="Zoom" i18n-aria-label>
@for (setting of zoomSettings; track setting) {
<option [value]="setting">
{{ getZoomSettingTitle(setting) }}
</option>
}
</select>
<button class="btn btn-outline-secondary" (click)="increaseZoom()" aria-label="Zoom in" i18n-aria-label>
<i-bs width="1em" height="1em" name="plus"></i-bs>
</button>
</div>
</div>
</div>
} @else {
<object [data]="previewUrl() | safeUrl" class="preview-sticky" width="100%"></object>
@@ -60,7 +60,6 @@ import { TagService } from 'src/app/services/rest/tag.service'
import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { TrashService } from 'src/app/services/trash.service'
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
import { environment } from 'src/environments/environment'
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
@@ -138,7 +137,6 @@ describe('DocumentDetailComponent', () => {
let openDocumentsService: OpenDocumentsService
let modalService: NgbModal
let toastService: ToastService
let trashService: TrashService
let documentListViewService: DocumentListViewService
let settingsService: SettingsService
let customFieldsService: CustomFieldsService
@@ -287,7 +285,6 @@ describe('DocumentDetailComponent', () => {
documentService = TestBed.inject(DocumentService)
modalService = TestBed.inject(NgbModal)
toastService = TestBed.inject(ToastService)
trashService = TestBed.inject(TrashService)
documentListViewService = TestBed.inject(DocumentListViewService)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser.set({ id: 1 })
@@ -778,7 +775,7 @@ describe('DocumentDetailComponent', () => {
component.title.set('Foo Bar')
const closeSpy = jest.spyOn(component, 'close')
const patchSpy = jest.spyOn(documentService, 'patch')
const toastSpy = jest.spyOn(toastService, 'showSuccess')
const toastSpy = jest.spyOn(toastService, 'showInfo')
patchSpy.mockImplementation((o) => of(doc))
component.save(true)
expect(patchSpy).toHaveBeenCalled()
@@ -793,7 +790,7 @@ describe('DocumentDetailComponent', () => {
component.title.set('Foo Bar')
const closeSpy = jest.spyOn(component, 'close')
const patchSpy = jest.spyOn(documentService, 'patch')
const toastSpy = jest.spyOn(toastService, 'showSuccess')
const toastSpy = jest.spyOn(toastService, 'showInfo')
patchSpy.mockImplementation((o) => of(doc))
component.save()
expect(patchSpy).toHaveBeenCalled()
@@ -839,7 +836,7 @@ describe('DocumentDetailComponent', () => {
component.title.set('Foo Bar')
const closeSpy = jest.spyOn(component, 'close')
const patchSpy = jest.spyOn(documentService, 'patch')
const toastSpy = jest.spyOn(toastService, 'showSuccess')
const toastSpy = jest.spyOn(toastService, 'showInfo')
patchSpy.mockImplementation(() =>
throwError(() => new Error('failed to save'))
)
@@ -960,16 +957,12 @@ describe('DocumentDetailComponent', () => {
const modalSpy = jest.spyOn(modalService, 'open')
const deleteSpy = jest.spyOn(documentService, 'delete')
deleteSpy.mockReturnValue(of(true))
const notifySpy = jest
.spyOn(trashService, 'notifyMovedToTrash')
.mockImplementation(() => {})
component.delete()
expect(modalSpy).toHaveBeenCalled()
const modalCloseSpy = jest.spyOn(openModal, 'close')
openModal.componentInstance.confirmClicked.next()
expect(deleteSpy).toHaveBeenCalled()
expect(modalCloseSpy).toHaveBeenCalled()
expect(notifySpy).toHaveBeenCalledWith(expect.any(String), [doc.id])
})
it('should allow retry delete if error', () => {
@@ -95,7 +95,6 @@ import { TagService } from 'src/app/services/rest/tag.service'
import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { TrashService } from 'src/app/services/trash.service'
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
import { getFilenameFromContentDisposition } from 'src/app/utils/http'
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
@@ -222,7 +221,6 @@ export class DocumentDetailComponent
private documentListViewService = inject(DocumentListViewService)
private documentTitlePipe = inject(DocumentTitlePipe)
private toastService = inject(ToastService)
private trashService = inject(TrashService)
private settings = inject(SettingsService)
private storagePathService = inject(StoragePathService)
private permissionsService = inject(PermissionsService)
@@ -1351,7 +1349,7 @@ export class DocumentDetailComponent
this.store.next(newValues)
this.openDocumentService.setDirty(this.document(), false)
this.openDocumentService.save()
this.toastService.showSuccess(
this.toastService.showInfo(
$localize`Document "${newValues.title}" saved successfully.`
)
this.networkActive.set(false)
@@ -1379,7 +1377,7 @@ export class DocumentDetailComponent
if (!canEdit) {
// document was 'given away'
this.openDocumentService.setDirty(this.document(), false)
this.toastService.showSuccess(
this.toastService.showInfo(
$localize`Document "${this.document().title}" saved successfully.`
)
this.close()
@@ -1492,10 +1490,6 @@ export class DocumentDetailComponent
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({
next: () => {
this.trashService.notifyMovedToTrash(
$localize`Moved "${this.document().title}" to the trash`,
[this.document().id]
)
modal.close()
this.close()
},
@@ -33,9 +33,7 @@ describe('DocumentVersionDropdownComponent', () => {
| 'updateVersionLabel'
>
>
let toastService: jest.Mocked<
Pick<ToastService, 'showError' | 'showInfo' | 'showSuccess'>
>
let toastService: jest.Mocked<Pick<ToastService, 'showError' | 'showInfo'>>
let finished$: Subject<{ taskId: string }>
let failed$: Subject<{ taskId: string; message?: string }>
let modalService: jest.Mocked<Pick<NgbModal, 'open'>>
@@ -57,7 +55,6 @@ describe('DocumentVersionDropdownComponent', () => {
toastService = {
showError: jest.fn(),
showInfo: jest.fn(),
showSuccess: jest.fn(),
}
permissionsService = {
currentUserCan: jest.fn().mockReturnValue(true),
@@ -419,6 +416,6 @@ describe('DocumentVersionDropdownComponent', () => {
expect(selectedEmitSpy).toHaveBeenCalledWith(2)
expect(component.newVersionLabel).toEqual('')
expect(modal.close).toHaveBeenCalled()
expect(toastService.showSuccess).toHaveBeenCalled()
expect(toastService.showInfo).toHaveBeenCalled()
})
})
@@ -320,7 +320,7 @@ export class DocumentVersionDropdownComponent
}
this.newVersionLabel = ''
modal.close()
this.toastService.showSuccess(
this.toastService.showInfo(
$localize`Existing document added as a version.`
)
},
@@ -87,16 +87,16 @@
</button>
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
<button ngbDropdownItem (click)="reprocessSelected()" [disabled]="!userCanEditAll && !userCanEditAll">
<i-bs name="body-text" class="me-2"></i-bs><ng-container i18n>Reprocess</ng-container>
<i-bs name="body-text" class="me-1"></i-bs><ng-container i18n>Reprocess</ng-container>
</button>
<button ngbDropdownItem (click)="rotateSelected()" [disabled]="!userOwnsAll && !userCanEditAll">
<i-bs name="arrow-clockwise" class="me-2"></i-bs><ng-container i18n>Rotate</ng-container>
<i-bs name="arrow-clockwise" class="me-1"></i-bs><ng-container i18n>Rotate</ng-container>
</button>
<button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.allSelected || list.selectedCount < 2">
<i-bs name="journals" class="me-2"></i-bs><ng-container i18n>Merge</ng-container>
<i-bs name="journals" class="me-1"></i-bs><ng-container i18n>Merge</ng-container>
</button>
<button ngbDropdownItem (click)="mergeSelectedAsVersions()" [disabled]="!userOwnsAll || !userCanEditAll || !userCanDelete || list.allSelected || list.selectedCount < 2">
<i-bs name="journal-bookmark-fill" class="me-2"></i-bs><ng-container i18n>Merge as versions</ng-container>
<i-bs name="journal-bookmark-fill" class="me-1"></i-bs><ng-container i18n>Merge as versions</ng-container>
</button>
</div>
</div>
@@ -116,12 +116,12 @@
<div ngbDropdownMenu aria-labelledby="dropdownSend" class="shadow">
@if (permissionService.currentUserCan(PermissionAction.Add, PermissionType.ShareLinkBundle)) {
<button ngbDropdownItem (click)="createShareLinkBundle()" [disabled]="!canSendSelection">
<i-bs name="link" class="me-2"></i-bs><ng-container i18n>Create a share link bundle</ng-container>
<i-bs name="link" class="me-1"></i-bs><ng-container i18n>Create a share link bundle</ng-container>
</button>
}
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.ShareLinkBundle)) {
<button ngbDropdownItem (click)="manageShareLinkBundles()">
<i-bs name="list-ul" class="me-2"></i-bs><ng-container i18n>Manage share link bundles</ng-container>
<i-bs name="list-ul" class="me-1"></i-bs><ng-container i18n>Manage share link bundles</ng-container>
</button>
}
@if (
@@ -133,7 +133,7 @@
}
@if (emailEnabled) {
<button ngbDropdownItem (click)="emailSelected()" [disabled]="!canSendSelection">
<i-bs name="envelope" class="me-2"></i-bs><ng-container i18n>Email</ng-container>
<i-bs name="envelope" class="me-1"></i-bs><ng-container i18n>Email</ng-container>
</button>
}
</div>
@@ -39,7 +39,6 @@ import { TagService } from 'src/app/services/rest/tag.service'
import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { TrashService } from 'src/app/services/trash.service'
import { environment } from 'src/environments/environment'
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
@@ -75,7 +74,6 @@ describe('BulkEditorComponent', () => {
let documentListViewService: DocumentListViewService
let documentService: DocumentService
let toastService: ToastService
let trashService: TrashService
let modalService: NgbModal
let tagService: TagService
let correspondentsService: CorrespondentService
@@ -187,7 +185,6 @@ describe('BulkEditorComponent', () => {
documentListViewService = TestBed.inject(DocumentListViewService)
documentService = TestBed.inject(DocumentService)
toastService = TestBed.inject(ToastService)
trashService = TestBed.inject(TrashService)
modalService = TestBed.inject(NgbModal)
tagService = TestBed.inject(TagService)
correspondentsService = TestBed.inject(CorrespondentService)
@@ -1167,14 +1164,10 @@ describe('BulkEditorComponent', () => {
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/delete/`
)
const notifySpy = jest
.spyOn(trashService, 'notifyMovedToTrash')
.mockImplementation(() => {})
req.flush(true)
expect(req.request.body).toEqual({
documents: [3, 4],
})
expect(notifySpy).toHaveBeenCalledWith(expect.any(String), [3, 4])
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
@@ -1184,19 +1177,10 @@ describe('BulkEditorComponent', () => {
component.showConfirmationDialogs = false
fixture.detectChanges()
jest
.spyOn(documentListViewService, 'allSelected', 'get')
.mockReturnValue(true)
component.applyDelete()
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/delete/`
)
req.flush(true)
// ids aren't known when all matching documents are selected, so no undo
expect(notifySpy).toHaveBeenLastCalledWith(expect.any(String), null)
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
})
it('should not be accessible with insufficient global permissions', () => {
@@ -1432,7 +1416,7 @@ describe('BulkEditorComponent', () => {
const mergeAsVersionsSpy = jest
.spyOn(documentService, 'mergeDocumentsAsVersions')
.mockReturnValue(of(true))
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
fixture.detectChanges()
component.mergeSelectedAsVersions()
@@ -1448,9 +1432,7 @@ describe('BulkEditorComponent', () => {
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
)
expect(documentListViewService.selected.size).toEqual(0)
expect(toastSuccessSpy).toHaveBeenCalledWith(
'Documents merged as versions.'
)
expect(toastInfoSpy).toHaveBeenCalledWith('Documents merged as versions.')
})
it('should not report success when merging documents as versions fails', () => {
@@ -1476,7 +1458,7 @@ describe('BulkEditorComponent', () => {
jest
.spyOn(documentService, 'mergeDocumentsAsVersions')
.mockReturnValue(throwError(() => new Error('failed')))
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const toastErrorSpy = jest.spyOn(toastService, 'showError')
fixture.detectChanges()
@@ -1485,7 +1467,7 @@ describe('BulkEditorComponent', () => {
modal.componentInstance.confirm()
expect(toastErrorSpy).toHaveBeenCalled()
expect(toastSuccessSpy).not.toHaveBeenCalled()
expect(toastInfoSpy).not.toHaveBeenCalled()
})
it('should support bulk download with archive, originals or both and file formatting', () => {
@@ -1864,7 +1846,7 @@ describe('BulkEditorComponent', () => {
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([3, 4]))
fixture.detectChanges()
const toastServiceShowInfoSpy = jest.spyOn(toastService, 'showSuccess')
const toastServiceShowInfoSpy = jest.spyOn(toastService, 'showInfo')
const toastServiceShowErrorSpy = jest.spyOn(toastService, 'showError')
const listReloadSpy = jest.spyOn(documentListViewService, 'reload')
@@ -20,15 +20,7 @@ import {
} from '@ng-bootstrap/ng-bootstrap'
import { saveAs } from 'file-saver'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import {
first,
map,
Observable,
Subject,
switchMap,
takeUntil,
tap,
} from 'rxjs'
import { first, map, Observable, Subject, switchMap, takeUntil } from 'rxjs'
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
import { CustomField } from 'src/app/data/custom-field'
import { MatchingModel } from 'src/app/data/matching-model'
@@ -57,7 +49,6 @@ import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { TagService } from 'src/app/services/rest/tag.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { TrashService } from 'src/app/services/trash.service'
import { flattenTags } from 'src/app/utils/flatten-tags'
import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
import { MergeAsVersionsConfirmDialogComponent } from '../../common/confirm-dialog/merge-as-versions-confirm-dialog/merge-as-versions-confirm-dialog.component'
@@ -108,7 +99,6 @@ export class BulkEditorComponent
private openDocumentService = inject(OpenDocumentsService)
private settings = inject(SettingsService)
private toastService = inject(ToastService)
private trashService = inject(TrashService)
private storagePathService = inject(StoragePathService)
private customFieldService = inject(CustomFieldsService)
public readonly permissionService = inject(PermissionsService)
@@ -316,7 +306,7 @@ export class BulkEditorComponent
next: () => {
this.handleOperationSuccess(modal, options.clearSelection ?? false)
if (options.successMessage) {
this.toastService.showSuccess(options.successMessage)
this.toastService.showInfo(options.successMessage)
}
},
error: (error) => this.handleOperationError(modal, error),
@@ -894,33 +884,19 @@ export class BulkEditorComponent
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
this.deleteSelected(modal)
this.executeDocumentAction(
modal,
this.documentService.deleteDocuments(this.getSelectionQuery())
)
})
} else {
this.deleteSelected(null)
this.executeDocumentAction(
null,
this.documentService.deleteDocuments(this.getSelectionQuery())
)
}
}
private deleteSelected(modal: NgbModalRef) {
const documents = this.list.allSelected
? null
: Array.from(this.list.selected)
const count = this.getSelectionSize()
this.executeDocumentAction(
modal,
this.documentService
.deleteDocuments(this.getSelectionQuery())
.pipe(
tap(() =>
this.trashService.notifyMovedToTrash(
$localize`Moved ${count} document(s) to the trash`,
documents
)
)
)
)
}
downloadSelected() {
this.awaitingDownload.set(true)
let downloadFileType: string =
@@ -1089,7 +1065,7 @@ export class BulkEditorComponent
dialog.selection = this.getSelectionQuery()
dialog.selectionCount = this.getSelectionSize()
dialog.succeeded.subscribe((result) => {
this.toastService.showSuccess($localize`Custom fields updated.`)
this.toastService.showInfo($localize`Custom fields updated.`)
this.list.reload()
this.list.reduceSelectionToFilter()
this.list.selected.forEach((id) => {
@@ -487,7 +487,7 @@ describe('DocumentListComponent', () => {
delete modifiedView.name
const savedViewServicePatch = jest.spyOn(savedViewService, 'patch')
savedViewServicePatch.mockReturnValue(of(modifiedView))
const toastSpy = jest.spyOn(toastService, 'showSuccess')
const toastSpy = jest.spyOn(toastService, 'showInfo')
component.saveViewConfig()
expect(savedViewServicePatch).toHaveBeenCalledWith(modifiedView)
@@ -592,7 +592,7 @@ describe('DocumentListComponent', () => {
let openModal: NgbModalRef
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
const modalSpy = jest.spyOn(modalService, 'open')
const toastSpy = jest.spyOn(toastService, 'showSuccess')
const toastSpy = jest.spyOn(toastService, 'showInfo')
const savedViewServiceCreate = jest.spyOn(savedViewService, 'create')
jest
.spyOn(savedViewService, 'dashboardViews', 'get')
@@ -418,7 +418,7 @@ export class DocumentListComponent
next: (view) => {
this.activeSavedView = view
this.unmodifiedSavedView = view
this.toastService.showSuccess(
this.toastService.showInfo(
$localize`View "${this.list.activeSavedViewTitle}" saved successfully.`
)
this.unmodifiedFilterRules.set(this.list.filterRules)
@@ -493,7 +493,7 @@ export class DocumentListComponent
.subscribe({
next: () => {
modal.close()
this.toastService.showSuccess(
this.toastService.showInfo(
$localize`View "${savedView.name}" created successfully.`
)
},
@@ -119,7 +119,7 @@ describe('FileDropComponent', () => {
}
component.onDrop(dragEvent as DragEvent)
advanceTimers(3000)
expect(toastSpy).not.toHaveBeenCalled()
expect(toastSpy).toHaveBeenCalled()
expect(uploadSpy).toHaveBeenCalled()
})
@@ -159,7 +159,7 @@ describe('FileDropComponent', () => {
component.onDrop(dragEvent as DragEvent)
await flushPromises()
advanceTimers(3000)
expect(toastSpy).not.toHaveBeenCalled()
expect(toastSpy).toHaveBeenCalled()
expect(uploadSpy).toHaveBeenCalled()
})
@@ -227,7 +227,7 @@ describe('FileDropComponent', () => {
}
component.onDrop(dragEvent as DragEvent)
advanceTimers(3000)
expect(toastSpy).not.toHaveBeenCalled()
expect(toastSpy).toHaveBeenCalled()
expect(uploadSpy).toHaveBeenCalled()
})
@@ -134,7 +134,8 @@ export class FileDropComponent {
Promise.all(promises)
.then((results) => {
files.push(...[].concat(...results))
this.uploadDocumentsService.uploadFiles(files)
this.toastService.showInfo($localize`Initiating upload...`, 3000)
files.forEach((file) => this.uploadDocumentsService.uploadFile(file))
})
.catch((e) => {
this.toastService.showError(
@@ -142,7 +143,8 @@ export class FileDropComponent {
)
})
} else if (files.length) {
this.uploadDocumentsService.uploadFiles(files)
this.toastService.showInfo($localize`Initiating upload...`, 3000)
files.forEach((file) => this.uploadDocumentsService.uploadFile(file))
}
this.onDragLeave(event, true)
@@ -107,7 +107,7 @@ describe('CustomFieldsComponent', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const reloadSpy = jest.spyOn(component, 'reload')
component.editField()
@@ -131,7 +131,7 @@ describe('CustomFieldsComponent', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const reloadSpy = jest.spyOn(component, 'reload')
const editButton = fixture.debugElement
@@ -82,9 +82,7 @@ export class CustomFieldsComponent
modal.componentInstance.succeeded
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((newField) => {
this.toastService.showSuccess(
$localize`Saved field "${newField.name}".`
)
this.toastService.showInfo($localize`Saved field "${newField.name}".`)
this.customFieldsService.clearCache()
this.settingsService.initializeDisplayFields()
this.documentService.reload()
@@ -111,9 +109,7 @@ export class CustomFieldsComponent
this.customFieldsService.delete(field).subscribe({
next: () => {
modal.close()
this.toastService.showSuccess(
$localize`Deleted field "${field.name}"`
)
this.toastService.showInfo($localize`Deleted field "${field.name}"`)
this.customFieldsService.clearCache()
this.settingsService.initializeDisplayFields()
this.documentService.reload()
@@ -160,7 +160,7 @@ describe('ManagementListComponent', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const reloadSpy = jest.spyOn(component, 'reloadData')
component.openCreateDialog()
@@ -183,7 +183,7 @@ describe('ManagementListComponent', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const reloadSpy = jest.spyOn(component, 'reloadData')
component.openEditDialog(tags[0])
@@ -387,7 +387,7 @@ describe('ManagementListComponent', () => {
expect(bulkEditPermsSpy).toHaveBeenCalled()
expect(errorToastSpy).toHaveBeenCalled()
const successToastSpy = jest.spyOn(toastService, 'showSuccess')
const successToastSpy = jest.spyOn(toastService, 'showInfo')
bulkEditPermsSpy.mockReturnValueOnce(of('OK'))
modal.componentInstance.confirmClicked.emit({
permissions: {},
@@ -452,7 +452,7 @@ describe('ManagementListComponent', () => {
)
expect(errorToastSpy).toHaveBeenCalled()
const successToastSpy = jest.spyOn(toastService, 'showSuccess')
const successToastSpy = jest.spyOn(toastService, 'showInfo')
bulkEditSpy.mockReturnValueOnce(of('OK'))
modal.componentInstance.confirmClicked.emit(null)
expect(bulkEditSpy).toHaveBeenCalled()
@@ -224,7 +224,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
activeModal.componentInstance.dialogMode.set(EditDialogMode.CREATE)
activeModal.componentInstance.succeeded.subscribe(() => {
this.reloadData()
this.toastService.showSuccess(
this.toastService.showInfo(
$localize`Successfully created ${this.typeName}.`
)
})
@@ -244,7 +244,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
activeModal.componentInstance.dialogMode.set(EditDialogMode.EDIT)
activeModal.componentInstance.succeeded.subscribe(() => {
this.reloadData()
this.toastService.showSuccess(
this.toastService.showInfo(
$localize`Successfully updated ${this.typeName} "${object.name}".`
)
})
@@ -428,7 +428,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
.subscribe({
next: () => {
modal.close()
this.toastService.showSuccess(
this.toastService.showInfo(
$localize`Permissions updated successfully`
)
this.reloadData()
@@ -468,9 +468,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
.subscribe({
next: () => {
modal.close()
this.toastService.showSuccess(
$localize`Objects deleted successfully`
)
this.toastService.showInfo($localize`Objects deleted successfully`)
this.reloadData()
},
error: (error) => {
@@ -184,7 +184,7 @@ describe('MailComponent', () => {
component.editMailAccount(mailAccounts[0] as MailAccount)
let editDialog = modal.componentInstance as MailAccountEditDialogComponent
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
editDialog.failed.emit()
expect(toastErrorSpy).toHaveBeenCalled()
editDialog.succeeded.emit(mailAccounts[0] as any)
@@ -203,7 +203,7 @@ describe('MailComponent', () => {
const deleteDialog = modal.componentInstance as ConfirmDialogComponent
const deleteSpy = jest.spyOn(mailAccountService, 'delete')
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const listAllSpy = jest.spyOn(mailAccountService, 'listAll')
deleteSpy.mockReturnValueOnce(
throwError(() => new Error('error deleting mail account'))
@@ -242,7 +242,7 @@ describe('MailComponent', () => {
component.editMailRule(mailRules[0] as MailRule)
const editDialog = modal.componentInstance as MailRuleEditDialogComponent
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
editDialog.failed.emit()
expect(toastErrorSpy).toHaveBeenCalled()
editDialog.succeeded.emit(mailRules[0] as any)
@@ -272,7 +272,7 @@ describe('MailComponent', () => {
const deleteDialog = modal.componentInstance as ConfirmDialogComponent
const deleteSpy = jest.spyOn(mailRuleService, 'delete')
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const listAllSpy = jest.spyOn(mailRuleService, 'listAll')
deleteSpy.mockReturnValueOnce(
throwError(() => new Error('error deleting mail rule "rule1"'))
@@ -303,7 +303,7 @@ describe('MailComponent', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const rulePatchSpy = jest.spyOn(mailRuleService, 'patch')
component.editPermissions(mailRules[0] as MailRule)
expect(modal).not.toBeUndefined()
@@ -356,7 +356,7 @@ describe('MailComponent', () => {
By.css('input[type="checkbox"]')
)
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
// fail first
patchSpy.mockReturnValueOnce(
throwError(() => new Error('Error getting config'))
@@ -380,7 +380,7 @@ describe('MailComponent', () => {
jest
.spyOn(activatedRoute, 'queryParamMap', 'get')
.mockReturnValue(of(convertToParamMap(queryParams)))
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
completeSetup()
expect(toastInfoSpy).toHaveBeenCalled()
})
@@ -135,9 +135,7 @@ export class MailComponent
if (params.get('oauth_success')) {
const success = params.get('oauth_success') === '1'
if (success) {
this.toastService.showSuccess(
$localize`OAuth2 authentication success`
)
this.toastService.showInfo($localize`OAuth2 authentication success`)
this.oAuthAccountId = parseInt(params.get('account_id'))
if (this.mailAccounts().length > 0) {
this.editMailAccount(
@@ -171,7 +169,7 @@ export class MailComponent
modal.componentInstance.succeeded
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((newMailAccount) => {
this.toastService.showSuccess(
this.toastService.showInfo(
$localize`Saved account "${newMailAccount.name}".`
)
this.mailAccountService.clearCache()
@@ -202,7 +200,7 @@ export class MailComponent
this.mailAccountService.delete(account).subscribe({
next: () => {
modal.close()
this.toastService.showSuccess(
this.toastService.showInfo(
$localize`Deleted mail account "${account.name}"`
)
this.mailAccountService.clearCache()
@@ -250,9 +248,7 @@ export class MailComponent
modal.componentInstance.succeeded
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((newMailRule) => {
this.toastService.showSuccess(
$localize`Saved rule "${newMailRule.name}".`
)
this.toastService.showInfo($localize`Saved rule "${newMailRule.name}".`)
this.mailRuleService.clearCache()
this.mailRuleService
.listAll(null, null, { full_perms: true })
@@ -277,7 +273,7 @@ export class MailComponent
onMailRuleEnableToggled(rule: MailRule) {
this.mailRuleService.patch(rule).subscribe({
next: () => {
this.toastService.showSuccess(
this.toastService.showInfo(
rule.enabled
? $localize`Rule "${rule.name}" enabled.`
: $localize`Rule "${rule.name}" disabled.`
@@ -306,7 +302,7 @@ export class MailComponent
this.mailRuleService.delete(rule).subscribe({
next: () => {
modal.close()
this.toastService.showSuccess(
this.toastService.showInfo(
$localize`Deleted mail rule "${rule.name}"`
)
this.mailRuleService.clearCache()
@@ -342,7 +338,7 @@ export class MailComponent
object['set_permissions'] = permissions['set_permissions']
service.patch(object).subscribe({
next: () => {
this.toastService.showSuccess($localize`Permissions updated`)
this.toastService.showInfo($localize`Permissions updated`)
modal.close()
},
error: (e) => {
@@ -96,7 +96,7 @@ describe('ProcessedMailDialogComponent', () => {
// select a couple of mails and delete
component.selectedMailIds().add(5)
component.selectedMailIds().add(6)
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
component.deleteSelected()
const delReq = httpTestingController.expectOne(
@@ -67,7 +67,7 @@ export class ProcessedMailDialogComponent implements OnInit {
this.processedMailService
.bulk_delete(Array.from(this.selectedMailIds()))
.subscribe(() => {
this.toastService.showSuccess($localize`Processed mail(s) deleted`)
this.toastService.showInfo($localize`Processed mail(s) deleted`)
this.loadProcessedMails()
})
}
@@ -227,7 +227,7 @@ describe('SavedViewsComponent', () => {
})
it('should support delete saved view', () => {
const toastSpy = jest.spyOn(toastService, 'showSuccess')
const toastSpy = jest.spyOn(toastService, 'showInfo')
const deleteSpy = jest.spyOn(savedViewService, 'delete')
deleteSpy.mockReturnValue(of(true))
component.deleteSavedView(savedViews[0] as SavedView)
Loaded 100 of 146 files, more files were not shown because too many files have changed in this diff. Show more