Compare commits

...
Author SHA1 Message Date
shamoon c718636f79 Update usage.md 2026-10-20 17:07:09 -07:00
shamoonandOpenAI Codex 54f7f43f35 Reuse OCR page renders and simplify value conversion
Co-authored-by: OpenAI Codex <codex@openai.com>
2026-10-20 17:07:00 -07:00
shamoonandOpenAI Codex 94da3d4c57 Report actual OCR extraction outcomes instead of existing field values
Co-authored-by: OpenAI Codex <codex@openai.com>
2026-10-20 17:02:23 -07:00
shamoonandOpenAI Codex 453e89d6dd Prepare and atomically apply OCR template field updates
Co-authored-by: OpenAI Codex <codex@openai.com>
2026-10-20 17:00:03 -07:00
shamoonandOpenAI Codex 6562415894 Unify OCR zone extraction for preview and saved templates
Co-authored-by: OpenAI Codex <codex@openai.com>
2026-10-20 16:58:10 -07:00
shamoon 257c33453d Update test_api_ocr_templates.py 2026-10-20 16:49:39 -07:00
shamoon 87913411ef Update serialisers.py 2026-10-20 16:49:35 -07:00
shamoon cec393dcae Update views.py 2026-10-20 16:49:32 -07:00
shamoon f5526d5700 Improve backend tests 2026-10-20 16:47:48 -07:00
shamoon 2534d3c16b Absurd comments 2026-10-20 16:46:21 -07:00
shamoon 95c8bf2058 Cleanup the serializers and views 2026-10-20 16:45:04 -07:00
shamoon 930d23956b Up frontend coverage 2026-10-20 16:34:22 -07:00
shamoon d2e6834602 Make sort order stable 2026-10-20 16:28:31 -07:00
shamoon a7bf04ddee Update spacing 2026-10-20 16:17:56 -07:00
shamoon 8ab2bf63aa Merge branch 'dev' into feature/ocr-templates 2026-10-20 16:16:31 -07:00
shamoon 040ceb8503 Fix: standardize some dropdown sizing (#14510) 2026-10-20 16:16:05 -07:00
shamoon c63a7e25e7 Update docs 2026-10-20 15:37:08 -07:00
shamoon 9171a1aa6a Changeup the icon 2026-10-20 15:33:01 -07:00
shamoon 55c5d9121f Yes, cute little split button that opens the dialog 2026-10-20 15:31:48 -07:00
shamoon e4efb62667 And bulk edit 2026-10-20 14:02:04 -07:00
shamoon f0597a7c54 doc detail use it this way 2026-10-20 14:00:25 -07:00
shamoon bbdc016a32 Backend for manual tempalte choice 2026-10-20 13:51:54 -07:00
shamoon eae392469f Update the frontend for the matching stuff 2026-10-20 13:41:55 -07:00
shamoon bedefd85f2 Remove the handlers, make the matching work 2026-10-20 13:39:09 -07:00
shamoon bd73fe1e80 Update the serializer 2026-10-20 13:31:08 -07:00
shamoon 8b77a924dc Make it a matchingmodel 2026-10-20 13:29:42 -07:00
shamoon 1bdeb7ebcd Mas sonar 2026-10-20 13:15:17 -07:00
shamoon fa3acfe13f Sure sonar 2026-10-20 13:12:26 -07:00
shamoon 2bc22b14bd Make perms accordion 2026-10-20 13:03:04 -07:00
shamoon dbce8c1409 User perms on frontend 2026-10-20 11:25:48 -07:00
shamoon e976b2ec7a Tests 2026-10-20 11:23:44 -07:00
Trenton HandClaude Sonnet 5.5 c089bba32c Chore: Drop ImageMagik as a dependency (#14440)
* Mark the thumbnail constants Final

* Comment each pdftoppm option

* Feature/Chore: Replaces all usages of ImageMagik's convert binary with a simple Pillow based helper

This removes ImageMagik as a dependency entirly and all documentation is cleaned up from it.
We no longer need to install it, have a policy for it to allow PDFs and no longer shell
out to it either.

* Fix: Save WebP images losslessly when removing the alpha channel

Pillow's default WebP save is lossy, so a WebP with an alpha channel came
back from remove_alpha_channel with slightly shifted pixel values before
it reached OCR. Save WebP lossless; other formats are unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 11:22:43 -07:00
Trenton HandClaude Sonnet 5.5 a05afd654c Feature: Generate PDF thumbnails via poppler utils (#14420)
* Chore: Generate PDF thumbnails with pdftoppm and Pillow instead of ImageMagick

Replace the ImageMagick convert based PDF thumbnail path with pdftoppm and
Pillow. Keeps thumbnails at 500px wide for small and odd sized pages, covers
the qpdf repair and double failure paths, removes the unused run_convert
helper, hardens thumbnail encoding, deprecates PAPERLESS_CONVERT_MEMORY_LIMIT
and PAPERLESS_CONVERT_TMPDIR, and corrects the related docs and CI.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Chore: Supersample the PDF thumbnail render 2x

Render the PDF page at twice the target size and downsample, with edge case
handling for small and odd sized pages, simplified helpers and tests, and
trimmed comments.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Fix: use a fractional thumbnail DPI so oversized pages render a bounded raster

* Deprecate the unused PAPERLESS_GS_BINARY setting too

* Warn at startup when pdftoppm is missing

* Resample PDF thumbnails once instead of twice with LANCZOS, still never enlarging

* Read PDF page size from pdfinfo so it matches pdftoppm

* Mark the thumbnail constants Final

* Comment each pdftoppm option
2026-10-20 11:22:42 -07:00
shamoon 05da0ba5a3 Apply only visible templates 2026-10-20 11:15:31 -07:00
shamoon 4ff2cb9c72 Make them owned, duh
Update 0028_ocrtemplate_ocrtemplatezone.py
2026-10-20 11:15:30 -07:00
shamoon 77a7fb40ec safe regex 2026-10-20 11:05:49 -07:00
shamoon 8100a8e1eb Lol and run zone ocr 2026-10-20 11:04:17 -07:00
shamoon 1e334903f3 And test zone 2026-10-20 11:01:11 -07:00
shamoon a958d89bf4 Add perms to document_page_image 2026-10-20 10:58:54 -07:00
shamoon 8b3295fce0 Docs 2026-10-20 10:42:16 -07:00
shamoon 9783c4995f Fix the tests for now 2026-10-20 10:42:12 -07:00
shamoon e89495dc02 Frontend use the bulk edit 2026-10-20 10:32:48 -07:00
shamoon 8bb2f88015 Backend work for proper bulk zone ocr 2026-10-20 10:20:28 -07:00
shamoon 7d9b8c770d Cleanup doc detail run zone ocr 2026-10-20 10:16:58 -07:00
shamoon 3296fd7cb5 Formatting, remove hard-coded alpha stuff 2026-10-20 10:14:03 -07:00
shamoon bff41907c2 Move back controls, drop (lol) dnd 2026-10-20 10:08:43 -07:00
shamoon 592f882394 Get rid of the x for the picker 2026-10-20 10:06:40 -07:00
shamoon 29ea9cec66 Gosh so much cleanup 2026-10-20 10:03:55 -07:00
shamoon 123df2e826 Pngx conventions 2026-10-20 09:50:29 -07:00
shamoon 639827ec65 Lol, no not deu+eng default 2026-10-20 09:45:30 -07:00
shamoon b3106e408c Lots of enum cleanup 2026-10-20 09:44:37 -07:00
shamoon ea31b6a043 Make it close not leave page 2026-10-20 09:38:29 -07:00
shamoon 0572b54579 Proper zoneless stuff 2026-10-20 09:35:39 -07:00
shamoon 2afec3e0b7 Lots of dead code 2026-10-20 09:31:59 -07:00
shamoon 57019bccbe Cleanup save now that we're modal 2026-10-20 09:30:12 -07:00
shamoon 26b3964a20 Cleanup the geometry stuff 2026-10-20 09:26:42 -07:00
shamoon a2eeadca7f Fix the delete handler 2026-10-20 09:24:26 -07:00
shamoon ba5474ba81 Lol, just one icon 2026-10-20 09:20:06 -07:00
shamoon e3592804bd Embiggen 2026-10-20 09:18:36 -07:00
shamoon 2d54927be8 Fuck it, make it a modal like literally everything else, drop the routes 2026-10-20 09:14:52 -07:00
shamoon 1d2832ac30 Update ocr-template-editor.component.ts 2026-10-20 09:05:24 -07:00
shamoon 3321ba46d6 Automatically update the combine string when changing zone name 2026-10-20 09:00:17 -07:00
shamoon bf316cba8a Dont fail if one bulk fails, collect results separately 2026-10-20 08:57:24 -07:00
shamoon 36c31b3a73 Also prevent editing template while saving 2026-10-20 08:47:26 -07:00
shamoon d10d303980 Warn about losing changes, disable while zone ocr running 2026-10-20 08:36:22 -07:00
shamoon a51e6a71f4 Fix reload after running zone ocr 2026-10-20 08:20:44 -07:00
shamoon ebd8dba3bb Fix the no zones note 2026-10-20 08:20:25 -07:00
shamoon 3ed9586347 Remove the create link from doc details 2026-10-20 08:11:40 -07:00
shamoon 7055f7a745 Fix perms test 2026-10-20 08:07:46 -07:00
shamoon 8f7500ba64 Fix the signal thing 2026-10-20 08:03:33 -07:00
shamoon 816774815c Add a little warning for empty zones 2026-10-20 07:39:47 -07:00
shamoon c5e6748771 Use braces icon instead 2026-10-20 07:18:01 -07:00
shamoon 9b42170554 Make the floating controls movable 2026-10-20 07:15:18 -07:00
shamoon d2ed0c55a4 dirtycheck 2026-10-19 23:12:09 -07:00
shamoon 4d31063082 Default to empty field 2026-10-19 23:07:12 -07:00
shamoon a4fb9bf2b0 Drop the silly mode toggle 2026-10-19 23:04:35 -07:00
shamoon e028257da5 Cleanup the zone combine stuff 2026-10-19 22:55:23 -07:00
shamoon 68eb4fe96d Make the zone list more useful, show colors 2026-10-19 22:43:12 -07:00
shamoon d2d434535b Remove the slop quick create CF stuff 2026-10-19 22:36:17 -07:00
shamoon 1d75c0c99b Use pngx inputs, move test button, generally make less terrible 2026-10-19 22:31:33 -07:00
GitHub Actions f730513e79 Auto translate strings 2026-10-20 05:27:06 +00:00
shamoon d4826f50c9 Fix: prevent doc permissions queries on schema view (#14503) 2026-10-19 22:25:52 -07:00
shamoon 1de1261728 Ok thats a better start, two tabs 2026-10-19 22:23:50 -07:00
shamoon f5f33e0744 make the ui usable 2026-10-19 22:06:11 -07:00
shamoon c9d62afa50 Make it hideable too 2026-10-19 21:43:10 -07:00
shamoon b5a9353780 Move migration 2026-10-19 21:39:26 -07:00
shamoon cf6d16edcd Merge branch 'dev' into feature/ocr-templates 2026-10-19 21:37:23 -07:00
GitHub Actions 8bbca8a269 Auto translate strings 2026-10-20 03:34:03 +00:00
shamoon 3374debd6a Enhancement: add upload count to button, link to tray, reduce notifications (#14501) 2026-10-19 20:32:35 -07:00
GitHub Actions f8e93a535a Auto translate strings 2026-10-20 02:19:58 +00:00
shamoon 98fb7c3b63 Enhancement: notifications improvements (#14500) 2026-10-19 19:18:39 -07:00
GitHub Actions c657fceea5 Auto translate strings 2026-10-20 02:09:30 +00:00
shamoon d76943d57c Enhancement: doc details ux improvements (#14485) 2026-10-19 19:08:33 -07:00
shamoon a4003df7e8 Enhancement (QoL): allow independently toggling child + parent tags in WF tag fields (#14400) 2026-10-19 19:08:21 -07:00
shamoon f6f420a491 Tweak: drop the user dropdown caret (#14444) 2026-10-19 19:08:07 -07:00
shamoon c105486b6a Enhancement: navbar upload button (#14442) 2026-10-19 19:07:55 -07:00
GitHub Actions 8ea31cb3ce Auto translate strings 2026-10-20 02:05:59 +00:00
shamoon 4c124caee1 Enhancement: AI chat UX tweaks (#14415) 2026-10-19 19:04:50 -07:00
GitHub Actions 9e83864f83 Auto translate strings 2026-10-20 01:53:57 +00:00
shamoon 7b53a4b316 Enhancement: support markdown in AI chat (#14409) 2026-10-19 18:52:28 -07:00
shamoon 20b7a116fd Fix: dont defer tag assignment when barcode will not result in a split (#14497) 2026-10-19 15:37:04 -07:00
github-actions[bot] 8d3902454e Documentation: Add v3.3.3 changelog (#14492)
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
2026-10-19 10:28:42 -07:00
shamoon 551445773a Use SVG, get rid of all the manual redraw stuff 2026-09-09 12:43:11 -07:00
shamoon c54cb4a84b Use a real canvas interaction model
[skip ci]
2026-09-09 12:43:11 -07:00
shamoon af61044b63 Fix some permissions stuff, show error on toggle 2026-09-09 12:43:11 -07:00
shamoon 1ef5a6d3cf Get rid of absurd alert 2026-09-09 12:43:11 -07:00
shamoon 4da311f91f Proper data types
[skip ci]
2026-09-09 12:43:10 -07:00
shamoon cbb84649c7 Pull out the zone list into a sub-component 2026-09-09 12:43:10 -07:00
shamoon 8da734a495 Extract the geometry stuff a bit 2026-09-09 12:43:10 -07:00
shamoon 351671011d Remove afterviewinit, fix page change, custom date format per zone 2026-09-09 12:43:09 -07:00
15c95c251d Feature: OCR Templates (#13043)
[skip ci]

Signed-off-by: dependabot[bot] <support@github.com>
Co-Authored-By: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-Authored-By: stumpylog <797416+stumpylog@users.noreply.github.com>
Co-Authored-By: GitHub Actions <41898282+github-actions[bot]@users.noreply.github.com>
Co-Authored-By: shamoon <4887959+shamoon@users.noreply.github.com>
2026-09-09 12:43:09 -07:00
176 changed files with 13157 additions and 1741 deletions

No files matched your search

-16
View File
@@ -39,7 +39,6 @@ ARG RUNTIME_PACKAGES="\
ghostscript \ ghostscript \
gnupg \ gnupg \
icc-profiles-free \ icc-profiles-free \
imagemagick \
# PostgreSQL # PostgreSQL
postgresql-client \ postgresql-client \
# MySQL / MariaDB # MySQL / MariaDB
@@ -93,21 +92,6 @@ RUN set -eux \
https://github.com/paperless-ngx/builder/releases/download/jbig2enc-trixie-v${JBIG2ENC_VERSION}/jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \ 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 && 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 # Packages needed only for building a few quick Python
# dependencies # dependencies
ARG BUILD_PACKAGES="\ ARG BUILD_PACKAGES="\
+1 -4
View File
@@ -111,10 +111,7 @@ jobs:
timeout-minutes: 12 timeout-minutes: 12
uses: $/.github/actions/apt-install uses: $/.github/actions/apt-install
with: with:
packages: unpaper tesseract-ocr imagemagick ghostscript poppler-utils packages: unpaper tesseract-ocr ghostscript poppler-utils qpdf
- name: Configure ImageMagick
run: |
sudo cp docker/rootfs/etc/ImageMagick-6/paperless-policy.xml /etc/ImageMagick-7/policy.xml
- name: Install Python dependencies - name: Install Python dependencies
env: env:
PYTHON_VERSION: ${{ steps.setup-python.outputs.python-version }} PYTHON_VERSION: ${{ steps.setup-python.outputs.python-version }}
-3
View File
@@ -129,7 +129,6 @@ ARG RUNTIME_PACKAGES="\
ghostscript \ ghostscript \
gnupg \ gnupg \
icc-profiles-free \ icc-profiles-free \
imagemagick \
# PostgreSQL # PostgreSQL
postgresql-client \ postgresql-client \
# MySQL / MariaDB # MySQL / MariaDB
@@ -167,8 +166,6 @@ RUN set -eux \
https://github.com/paperless-ngx/builder/releases/download/jbig2enc-trixie-v${JBIG2ENC_VERSION}/jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \ https://github.com/paperless-ngx/builder/releases/download/jbig2enc-trixie-v${JBIG2ENC_VERSION}/jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \
&& echo "Installing jbig2enc" \ && echo "Installing jbig2enc" \
&& dpkg --install ./jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \ && 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" \ && echo "Cleaning up image layer" \
&& rm --force --verbose *.deb \ && rm --force --verbose *.deb \
&& rm --recursive --force --verbose /var/lib/apt/lists/* \ && rm --recursive --force --verbose /var/lib/apt/lists/* \
@@ -1,101 +0,0 @@
<?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>
+33
View File
@@ -1,5 +1,38 @@
# Changelog # Changelog
## paperless-ngx 3.3.3
### Bug Fixes
- Fix: fix global search input layout shifting [@shamoon](https://github.com/shamoon) ([#14491](https://github.com/paperless-ngx/paperless-ngx/pull/14491))
- Fix: dont include doc content on global search results [@shamoon](https://github.com/shamoon) ([#14488](https://github.com/paperless-ngx/paperless-ngx/pull/14488))
- Fix: remove some redundant pagers [@shamoon](https://github.com/shamoon) ([#14487](https://github.com/paperless-ngx/paperless-ngx/pull/14487))
- Fix: prevent weird wrapping of dashboard widget column titles [@shamoon](https://github.com/shamoon) ([#14486](https://github.com/paperless-ngx/paperless-ngx/pull/14486))
- Fix: dont paginate 'all' tasks view across sections [@shamoon](https://github.com/shamoon) ([#14484](https://github.com/paperless-ngx/paperless-ngx/pull/14484))
- Fix: use float parsing for importer manifest records with a passphrase [@stumpylog](https://github.com/stumpylog) ([#14483](https://github.com/paperless-ngx/paperless-ngx/pull/14483))
- Fix: respect subpath in logout, share link and auto-login redirects [@jaimevelaz](https://github.com/jaimevelaz) ([#14476](https://github.com/paperless-ngx/paperless-ngx/pull/14476))
- Fix: dont tie suggestions button to ai enabled any more [@shamoon](https://github.com/shamoon) ([#14478](https://github.com/paperless-ngx/paperless-ngx/pull/14478))
- Fix: handle single-class classifier in threshold prediction (#<!---->14471) [@stumpylog](https://github.com/stumpylog) ([#14473](https://github.com/paperless-ngx/paperless-ngx/pull/14473))
- Fix: keep counter and extension when shortening long public filenames [@stumpylog](https://github.com/stumpylog) ([#14472](https://github.com/paperless-ngx/paperless-ngx/pull/14472))
### All App Changes
<details>
<summary>10 changes</summary>
- Fix: fix global search input layout shifting [@shamoon](https://github.com/shamoon) ([#14491](https://github.com/paperless-ngx/paperless-ngx/pull/14491))
- Fix: dont include doc content on global search results [@shamoon](https://github.com/shamoon) ([#14488](https://github.com/paperless-ngx/paperless-ngx/pull/14488))
- Fix: remove some redundant pagers [@shamoon](https://github.com/shamoon) ([#14487](https://github.com/paperless-ngx/paperless-ngx/pull/14487))
- Fix: prevent weird wrapping of dashboard widget column titles [@shamoon](https://github.com/shamoon) ([#14486](https://github.com/paperless-ngx/paperless-ngx/pull/14486))
- Fix: dont paginate 'all' tasks view across sections [@shamoon](https://github.com/shamoon) ([#14484](https://github.com/paperless-ngx/paperless-ngx/pull/14484))
- Fix: use float parsing for importer manifest records with a passphrase [@stumpylog](https://github.com/stumpylog) ([#14483](https://github.com/paperless-ngx/paperless-ngx/pull/14483))
- Fix: respect subpath in logout, share link and auto-login redirects [@jaimevelaz](https://github.com/jaimevelaz) ([#14476](https://github.com/paperless-ngx/paperless-ngx/pull/14476))
- Fix: dont tie suggestions button to ai enabled any more [@shamoon](https://github.com/shamoon) ([#14478](https://github.com/paperless-ngx/paperless-ngx/pull/14478))
- Fix: handle single-class classifier in threshold prediction (#<!---->14471) [@stumpylog](https://github.com/stumpylog) ([#14473](https://github.com/paperless-ngx/paperless-ngx/pull/14473))
- Fix: keep counter and extension when shortening long public filenames [@stumpylog](https://github.com/stumpylog) ([#14472](https://github.com/paperless-ngx/paperless-ngx/pull/14472))
</details>
## paperless-ngx 3.3.2 ## paperless-ngx 3.3.2
### Bug Fixes ### Bug Fixes
+14 -20
View File
@@ -1315,29 +1315,17 @@ valid crontab(5) expression describing when to run.
#### [`PAPERLESS_CONVERT_MEMORY_LIMIT=<num>`](#PAPERLESS_CONVERT_MEMORY_LIMIT) {#PAPERLESS_CONVERT_MEMORY_LIMIT} #### [`PAPERLESS_CONVERT_MEMORY_LIMIT=<num>`](#PAPERLESS_CONVERT_MEMORY_LIMIT) {#PAPERLESS_CONVERT_MEMORY_LIMIT}
: On smaller systems, or even in the case of Very Large Documents, the !!! warning
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.
For more information on how to use this value, you should search the Deprecated and has no effect, since PDF thumbnails no longer use
web for "MAGICK_MEMORY_LIMIT". ImageMagick. It will be removed in a future release.
Defaults to 0, which disables the limit.
#### [`PAPERLESS_CONVERT_TMPDIR=<path>`](#PAPERLESS_CONVERT_TMPDIR) {#PAPERLESS_CONVERT_TMPDIR} #### [`PAPERLESS_CONVERT_TMPDIR=<path>`](#PAPERLESS_CONVERT_TMPDIR) {#PAPERLESS_CONVERT_TMPDIR}
: Similar to the memory limit, if you've got a small system and your !!! warning
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.
For more information on how to use this value, you should search the Deprecated and has no effect, since PDF thumbnails no longer use
web for "MAGICK_TMPDIR". ImageMagick. It will be removed in a future release.
Default is none, which disables the temporary directory.
#### [`PAPERLESS_APPS=<string>`](#PAPERLESS_APPS) {#PAPERLESS_APPS} #### [`PAPERLESS_APPS=<string>`](#PAPERLESS_APPS) {#PAPERLESS_APPS}
@@ -1885,11 +1873,17 @@ the literal path for that program.
#### [`PAPERLESS_CONVERT_BINARY=<path>`](#PAPERLESS_CONVERT_BINARY) {#PAPERLESS_CONVERT_BINARY} #### [`PAPERLESS_CONVERT_BINARY=<path>`](#PAPERLESS_CONVERT_BINARY) {#PAPERLESS_CONVERT_BINARY}
: Defaults to "convert". !!! warning
Deprecated and has no effect, since Paperless-ngx no longer uses
ImageMagick. It will be removed in a future release.
#### [`PAPERLESS_GS_BINARY=<path>`](#PAPERLESS_GS_BINARY) {#PAPERLESS_GS_BINARY} #### [`PAPERLESS_GS_BINARY=<path>`](#PAPERLESS_GS_BINARY) {#PAPERLESS_GS_BINARY}
: Defaults to "gs". !!! warning
Deprecated and has no effect, since nothing in Paperless-ngx calls
Ghostscript by this path. It will be removed in a future release.
## Docker-specific options {#docker} ## Docker-specific options {#docker}
+2 -13
View File
@@ -177,17 +177,16 @@ to a positive number to enable polling and disable native filesystem notificatio
- `pkg-config` for mysqlclient (python dependency) - `pkg-config` for mysqlclient (python dependency)
- `fonts-liberation` for generating thumbnails for plain text - `fonts-liberation` for generating thumbnails for plain text
files files
- `imagemagick` >= 6 for PDF conversion
- `gnupg` for decrypting GPG-encrypted email - `gnupg` for decrypting GPG-encrypted email
- `libpq-dev` for PostgreSQL - `libpq-dev` for PostgreSQL
- `libmagic-dev` for mime type detection - `libmagic-dev` for mime type detection
- `mariadb-client` for MariaDB compile time - `mariadb-client` for MariaDB compile time
- `poppler-utils` for barcode detection - `poppler-utils` for thumbnail generation and barcode detection
Use this list for your preferred package management: Use this list for your preferred package management:
``` ```
python3 python3-pip python3-dev imagemagick fonts-liberation gnupg libpq-dev default-libmysqlclient-dev pkg-config libmagic-dev poppler-utils python3 python3-pip python3-dev 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 These dependencies are required for OCRmyPDF, which is used for text
@@ -416,16 +415,6 @@ to a positive number to enable polling and disable native filesystem notificatio
You may need to change the path in the files. Example: You may need to change the path in the files. Example:
`ExecStart=/opt/paperless/.local/bin/celery --app paperless worker --loglevel INFO` `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.** **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 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. software has been patented until around 2017 and binary packages are not available for most distributions.
+79 -1
View File
@@ -109,7 +109,7 @@ Think of versions as **file history** for a document.
Paperless-ngx includes management lists for tags, correspondents, document types Paperless-ngx includes management lists for tags, correspondents, document types
and more. These areas allow you to view, add, edit, delete and manage permissions and more. These areas allow you to view, add, edit, delete and manage permissions
for these objects. You can also manage saved views, mail accounts, mail rules, for these objects. You can also manage saved views, mail accounts, mail rules,
workflows and more from the management sections. workflows, [OCR templates](#ocr-templates) and more from the management sections.
### Nested Tags ### Nested Tags
@@ -792,6 +792,84 @@ The following custom field types are supported:
- `Document Link`: reference(s) to other document(s) displayed as links, automatically creates a symmetrical link in reverse - `Document Link`: reference(s) to other document(s) displayed as links, automatically creates a symmetrical link in reverse
- `Select`: a pre-defined list of strings from which the user can choose - `Select`: a pre-defined list of strings from which the user can choose
## OCR Templates {#ocr-templates}
OCR templates read text from fixed areas ("zones") of a document and write it to a
[custom field](#custom-fields), or to the document's title, archive serial number or created date. They are
useful for documents that always share the same layout, such as bills from a particular utility. They allow you
to draw a zone around the account number once, and every new bill gets its account number filled in.
Like tags or document types, each template has a [matching algorithm](advanced_usage.md#matching) that
decides which documents it automatically applies to (or not), based on their content.
### Creating a template
1. Open **OCR Templates** under "Manage" and choose **Add Template**.
2. Give the template a name and set up its matching, for example **Any word** with the utility's name. Choose
**None** for a template that should only ever be run manually.
3. Search for a sample document with the layout the template is for. Its pages are shown next to the template
settings.
4. Draw zones on the document. Click a zone to select and edit it.
5. Choose the field each zone fills.
6. Use **Test** to run OCR on the selected zone of the sample document and see the text it finds and the value
that would be written.
Zones are positioned relative to the sample document's page, so templates work best when the documents they
match have an identical layout. Keep the matching specific enough that it only catches documents with that
layout.
### Zone settings
- **Page**: the page the zone is on. Turn on **Last page** for zones that are always on the final page,
regardless of how many pages a document has.
- **OCR language**: the Tesseract languages used to read the zone. Languages other than the defaults must be
installed, see [`PAPERLESS_OCR_LANGUAGES`](configuration.md#PAPERLESS_OCR_LANGUAGES).
- **Transform**: post-processing applied to the text, such as stripping whitespace, keeping only numbers,
parsing a date or reading a QR code or barcode instead of text.
- **Date format**: for the "Parse date" transform, the format of the date using
[Python date codes](https://docs.python.org/3/library/datetime.html#format-codes), e.g. `%d.%m.%Y`.
Leave it empty to detect the format automatically.
- **Validation regex**: a regular expression the whole value must match. Values that do not match are not
written.
Zones can fill custom fields of type text, long text, URL, date, integer, number, monetary or boolean.
Document link and select fields are not supported.
### Combining zones
When several zones fill the same field, a **Combined value** setting appears for that field. Use it to arrange
the zones' values with `{Zone name}` tokens and any text in between, for example `{Start} - {End}`. If it
is left empty, the values are joined with spaces in zone order.
### When templates run
When a document is added, every enabled template that matches its content runs automatically. A template is
only used for a document if the document's owner can view the template, so users' templates apply to their own
documents, and unowned templates apply to everyone's. Templates run in name order, so if two templates fill the
same field, the later one wins.
Turn off **Enabled** to pause a template without deleting it. Disabled templates never run automatically, but
can still be run manually.
### Running templates manually
To run templates on documents that are already in Paperless-ngx, open the **Actions** menu on the document
details page:
- **Run Zone OCR** runs every enabled template that matches the document.
- The button next to it opens a dialog where you can choose one template to run
instead. A template you choose runs even if it does not match the document or is disabled.
The bulk editor's **Run Zone OCR** action opens the same dialog for the selected documents. Bulk runs are
processed in the background.
!!! warning
Zone OCR overwrites the fields it fills. A zone that finds no value leaves its field unchanged.
Managing OCR templates requires the OCR template permissions. Running zone OCR on documents requires
permission to change them, and choosing a template requires permission to view it.
## PDF Actions ## PDF Actions
Paperless-ngx supports basic editing operations for PDFs (these operations currently cannot be performed on non-PDF files). When viewing an individual document you can Paperless-ngx supports basic editing operations for PDFs (these operations currently cannot be performed on non-PDF files). When viewing an individual document you can
-7
View File
@@ -50,8 +50,6 @@ PAPERLESS_SECRET_KEY=change-me
#PAPERLESS_OCR_ROTATE_PAGES=true #PAPERLESS_OCR_ROTATE_PAGES=true
#PAPERLESS_OCR_ROTATE_PAGES_THRESHOLD=12.0 #PAPERLESS_OCR_ROTATE_PAGES_THRESHOLD=12.0
#PAPERLESS_OCR_USER_ARGS={} #PAPERLESS_OCR_USER_ARGS={}
#PAPERLESS_CONVERT_MEMORY_LIMIT=0
#PAPERLESS_CONVERT_TMPDIR=/var/tmp/paperless
# Software tweaks # Software tweaks
@@ -87,8 +85,3 @@ PAPERLESS_SECRET_KEY=change-me
#PAPERLESS_TIKA_ENABLED=false #PAPERLESS_TIKA_ENABLED=false
#PAPERLESS_TIKA_ENDPOINT=http://localhost:9998 #PAPERLESS_TIKA_ENDPOINT=http://localhost:9998
#PAPERLESS_TIKA_GOTENBERG_ENDPOINT=http://localhost:3000 #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', 'abstract-paperless-service',
], ],
transformIgnorePatterns: [ transformIgnorePatterns: [
'node_modules/(?!.*(\\.mjs$|tslib|lodash-es|normalize-diacritics|@angular/common/locales/.*\\.js$))', 'node_modules/(?!.*(\\.mjs$|tslib|lodash-es|normalize-diacritics|marked|@angular/common/locales/.*\\.js$))',
], ],
moduleNameMapper: { moduleNameMapper: {
...esmPreset.moduleNameMapper, ...esmPreset.moduleNameMapper,
+665 -561
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -30,6 +30,7 @@
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"lodash-es": "^4.18.1", "lodash-es": "^4.18.1",
"marked": "~18.0.14",
"mime-names": "^1.0.0", "mime-names": "^1.0.0",
"ngx-bootstrap-icons": "^1.9.3", "ngx-bootstrap-icons": "^1.9.3",
"ngx-color": "^10.1.0", "ngx-color": "^10.1.0",
+10
View File
@@ -53,6 +53,9 @@ importers:
lodash-es: lodash-es:
specifier: ^4.18.1 specifier: ^4.18.1
version: 4.18.1 version: 4.18.1
marked:
specifier: ~18.0.14
version: 18.0.14
mime-names: mime-names:
specifier: ^1.0.0 specifier: ^1.0.0
version: 1.0.0 version: 1.0.0
@@ -3226,6 +3229,11 @@ packages:
make-error@1.3.6: make-error@1.3.6:
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
marked@18.0.14:
resolution: {integrity: sha512-mBHK6FBHuBAlhgRe88w9F0O1AbwwXJUcQibUbC/QcdTbVGAD7aWza+xt3N6oT/jCZx3/OMeS+8rnuiHZcQ9s7A==}
engines: {node: '>= 20'}
hasBin: true
material-colors@1.2.6: material-colors@1.2.6:
resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==} resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==}
@@ -7409,6 +7417,8 @@ snapshots:
make-error@1.3.6: {} make-error@1.3.6: {}
marked@18.0.14: {}
material-colors@1.2.6: {} material-colors@1.2.6: {}
merge-stream@2.0.0: {} merge-stream@2.0.0: {}
+13
View File
@@ -13,6 +13,7 @@ import { DocumentDetailComponent } from './components/document-detail/document-d
import { DocumentListComponent } from './components/document-list/document-list.component' import { DocumentListComponent } from './components/document-list/document-list.component'
import { DocumentAttributesComponent } from './components/manage/document-attributes/document-attributes.component' import { DocumentAttributesComponent } from './components/manage/document-attributes/document-attributes.component'
import { MailComponent } from './components/manage/mail/mail.component' import { MailComponent } from './components/manage/mail/mail.component'
import { OcrTemplatesComponent } from './components/manage/ocr-templates/ocr-templates.component'
import { SavedViewsComponent } from './components/manage/saved-views/saved-views.component' import { SavedViewsComponent } from './components/manage/saved-views/saved-views.component'
import { ShareLinksComponent } from './components/manage/share-links/share-links.component' import { ShareLinksComponent } from './components/manage/share-links/share-links.component'
import { WorkflowsComponent } from './components/manage/workflows/workflows.component' import { WorkflowsComponent } from './components/manage/workflows/workflows.component'
@@ -275,6 +276,18 @@ export const routes: Routes = [
componentName: 'WorkflowsComponent', componentName: 'WorkflowsComponent',
}, },
}, },
{
path: 'ocr-templates',
component: OcrTemplatesComponent,
canActivate: [PermissionsGuard],
data: {
requiredPermission: {
action: PermissionAction.View,
type: PermissionType.OcrTemplate,
},
componentName: 'OcrTemplatesComponent',
},
},
{ {
path: 'mail', path: 'mail',
component: MailComponent, component: MailComponent,
+15 -9
View File
@@ -19,6 +19,7 @@ import { PermissionsGuard } from './guards/permissions.guard'
import { HotKeyService } from './services/hot-key.service' import { HotKeyService } from './services/hot-key.service'
import { PermissionsService } from './services/permissions.service' import { PermissionsService } from './services/permissions.service'
import { SettingsService } from './services/settings.service' import { SettingsService } from './services/settings.service'
import { TasksService } from './services/tasks.service'
import { Toast, ToastService } from './services/toast.service' import { Toast, ToastService } from './services/toast.service'
import { import {
FileStatus, FileStatus,
@@ -34,6 +35,7 @@ describe('AppComponent', () => {
let toastService: ToastService let toastService: ToastService
let router: Router let router: Router
let settingsService: SettingsService let settingsService: SettingsService
let tasksService: TasksService
let hotKeyService: HotKeyService let hotKeyService: HotKeyService
beforeEach(async () => { beforeEach(async () => {
@@ -60,6 +62,7 @@ describe('AppComponent', () => {
websocketStatusService = TestBed.inject(WebsocketStatusService) websocketStatusService = TestBed.inject(WebsocketStatusService)
permissionsService = TestBed.inject(PermissionsService) permissionsService = TestBed.inject(PermissionsService)
settingsService = TestBed.inject(SettingsService) settingsService = TestBed.inject(SettingsService)
tasksService = TestBed.inject(TasksService)
toastService = TestBed.inject(ToastService) toastService = TestBed.inject(ToastService)
router = TestBed.inject(Router) router = TestBed.inject(Router)
hotKeyService = TestBed.inject(HotKeyService) hotKeyService = TestBed.inject(HotKeyService)
@@ -117,30 +120,33 @@ describe('AppComponent', () => {
expect(toast.action).toBeUndefined() expect(toast.action).toBeUndefined()
}) })
it('should display toast on document added', () => { it('should only reload tasks when a document is detected', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
const toastSpy = jest.spyOn(toastService, 'show') const toastSpy = jest.spyOn(toastService, 'show')
const reloadSpy = jest.spyOn(tasksService, 'reload')
const fileStatusSubject = new Subject<FileStatus>() const fileStatusSubject = new Subject<FileStatus>()
jest jest
.spyOn(websocketStatusService, 'onDocumentDetected') .spyOn(websocketStatusService, 'onDocumentDetected')
.mockReturnValue(fileStatusSubject) .mockReturnValue(fileStatusSubject)
component.ngOnInit() component.ngOnInit()
fileStatusSubject.next(new FileStatus()) fileStatusSubject.next(new FileStatus())
expect(toastSpy).toHaveBeenCalled() expect(reloadSpy).toHaveBeenCalled()
expect(toastSpy).not.toHaveBeenCalled()
}) })
it('should suppress dashboard notifications if set', () => { it('should add consumed documents to the bell without a popup', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest.spyOn(settingsService, 'get').mockReturnValue(true) const popupSpy = jest.fn()
jest.spyOn(router, 'url', 'get').mockReturnValue('/dashboard') toastService.showToast.subscribe(popupSpy)
const toastSpy = jest.spyOn(toastService, 'show') let toasts: Toast[] = []
toastService.getToasts().subscribe((t) => (toasts = [...t]))
const fileStatusSubject = new Subject<FileStatus>() const fileStatusSubject = new Subject<FileStatus>()
jest jest
.spyOn(websocketStatusService, 'onDocumentDetected') .spyOn(websocketStatusService, 'onDocumentConsumptionFinished')
.mockReturnValue(fileStatusSubject) .mockReturnValue(fileStatusSubject)
component.ngOnInit() component.ngOnInit()
fileStatusSubject.next(new FileStatus()) fileStatusSubject.next(new FileStatus())
expect(toastSpy).not.toHaveBeenCalled() expect(toasts).toHaveLength(1)
expect(popupSpy).not.toHaveBeenCalled()
}) })
it('should display toast on document failed', () => { it('should display toast on document failed', () => {
+19 -50
View File
@@ -14,7 +14,7 @@ import {
} from './services/permissions.service' } from './services/permissions.service'
import { SettingsService } from './services/settings.service' import { SettingsService } from './services/settings.service'
import { TasksService } from './services/tasks.service' import { TasksService } from './services/tasks.service'
import { ToastService } from './services/toast.service' import { ToastLevel, ToastService } from './services/toast.service'
import { WebsocketStatusService } from './services/websocket-status.service' import { WebsocketStatusService } from './services/websocket-status.service'
@Component({ @Component({
@@ -61,18 +61,6 @@ export class AppComponent implements OnInit, OnDestroy {
} }
} }
private showNotification(key) {
if (
this.router.url == '/dashboard' &&
this.settings.get(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD
)
) {
return false
}
return this.settings.get(key)
}
ngOnInit(): void { ngOnInit(): void {
this.websocketStatusService.connect() this.websocketStatusService.connect()
@@ -80,29 +68,22 @@ export class AppComponent implements OnInit, OnDestroy {
.onDocumentConsumptionFinished() .onDocumentConsumptionFinished()
.subscribe((status) => { .subscribe((status) => {
this.tasksService.reload() this.tasksService.reload()
if ( if (this.settings.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS)) {
this.showNotification(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS) const canView = this.permissionsService.currentUserCan(
) { PermissionAction.View,
if ( PermissionType.Document
this.permissionsService.currentUserCan( )
PermissionAction.View, // the upload status tray already shows this, so only keep a record
PermissionType.Document this.toastService.show({
) content: $localize`Document ${status.filename} was added to Paperless-ngx.`,
) { delay: 10000,
this.toastService.show({ level: ToastLevel.Success,
content: $localize`Document ${status.filename} was added to Paperless-ngx.`, silent: true,
delay: 10000, actionName: canView ? $localize`Open document` : undefined,
actionName: $localize`Open document`, action: canView
action: () => { ? () => this.router.navigate(['documents', status.documentId])
this.router.navigate(['documents', status.documentId]) : undefined,
}, })
})
} else {
this.toastService.show({
content: $localize`Document ${status.filename} was added to Paperless-ngx.`,
delay: 10000,
})
}
} }
}) })
@@ -110,9 +91,7 @@ export class AppComponent implements OnInit, OnDestroy {
.onDocumentConsumptionFailed() .onDocumentConsumptionFailed()
.subscribe((status) => { .subscribe((status) => {
this.tasksService.reload() this.tasksService.reload()
if ( if (this.settings.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED)) {
this.showNotification(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED)
) {
this.toastService.showError( this.toastService.showError(
$localize`Could not add ${status.filename}\: ${status.message}` $localize`Could not add ${status.filename}\: ${status.message}`
) )
@@ -121,18 +100,8 @@ export class AppComponent implements OnInit, OnDestroy {
this.newDocumentSubscription = this.websocketStatusService this.newDocumentSubscription = this.websocketStatusService
.onDocumentDetected() .onDocumentDetected()
.subscribe((status) => { .subscribe(() => {
this.tasksService.reload() this.tasksService.reload()
if (
this.showNotification(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT
)
) {
this.toastService.show({
content: $localize`Document ${status.filename} is being processed by Paperless-ngx.`,
delay: 5000,
})
}
}) })
this.hotKeyService this.hotKeyService
@@ -190,7 +190,7 @@ export class ConfigComponent
this.initialize(config) this.initialize(config)
this.store.next(config) this.store.next(config)
this.settingsService.initializeSettings().subscribe() this.settingsService.initializeSettings().subscribe()
this.toastService.showInfo($localize`Configuration updated`) this.toastService.showSuccess($localize`Configuration updated`)
}, },
error: (e) => { error: (e) => {
this.loading.set(false) this.loading.set(false)
@@ -217,7 +217,7 @@ export class ConfigComponent
this.initialize(config) this.initialize(config)
this.store.next(config) this.store.next(config)
this.settingsService.initializeSettings().subscribe() this.settingsService.initializeSettings().subscribe()
this.toastService.showInfo($localize`File successfully updated`) this.toastService.showSuccess($localize`File successfully updated`)
}, },
error: (e) => { error: (e) => {
this.loading.set(false) this.loading.set(false)
@@ -425,10 +425,8 @@
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<pngx-input-check i18n-title title="Show notifications when new documents are detected" formControlName="notificationsConsumerNewDocument"></pngx-input-check> <pngx-input-check i18n-title title="Add notifications when document processing completes successfully" formControlName="notificationsConsumerSuccess" i18n-hint hint="These are added to the notifications menu without a popup, processing progress is shown at the bottom of the screen."></pngx-input-check>
<pngx-input-check i18n-title title="Show notifications when document processing completes successfully" formControlName="notificationsConsumerSuccess"></pngx-input-check>
<pngx-input-check i18n-title title="Show notifications when document processing fails" formControlName="notificationsConsumerFailed"></pngx-input-check> <pngx-input-check i18n-title title="Show notifications when document processing fails" formControlName="notificationsConsumerFailed"></pngx-input-check>
<pngx-input-check i18n-title title="Suppress notifications on dashboard" formControlName="notificationsConsumerSuppressOnDashboard" i18n-hint hint="This will suppress all messages about document processing status on the dashboard."></pngx-input-check>
</div> </div>
</div> </div>
@@ -307,7 +307,7 @@ describe('SettingsComponent', () => {
expect(toastErrorSpy).toHaveBeenCalled() expect(toastErrorSpy).toHaveBeenCalled()
expect(storeSpy).toHaveBeenCalled() expect(storeSpy).toHaveBeenCalled()
expect(appearanceSettingsSpy).not.toHaveBeenCalled() expect(appearanceSettingsSpy).not.toHaveBeenCalled()
expect(setSpy).toHaveBeenCalledTimes(35) expect(setSpy).toHaveBeenCalledTimes(33)
expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [ expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
HideableSidebarItemID.Workflows, HideableSidebarItemID.Workflows,
]) ])
@@ -63,7 +63,7 @@ import {
SettingsService, SettingsService,
} from 'src/app/services/settings.service' } from 'src/app/services/settings.service'
import { SystemStatusService } from 'src/app/services/system-status.service' import { SystemStatusService } from 'src/app/services/system-status.service'
import { Toast, ToastService } from 'src/app/services/toast.service' import { Toast, ToastLevel, ToastService } from 'src/app/services/toast.service'
import { locationReload } from 'src/app/utils/navigation' import { locationReload } from 'src/app/utils/navigation'
import { CheckComponent } from '../../common/input/check/check.component' import { CheckComponent } from '../../common/input/check/check.component'
import { ColorComponent } from '../../common/input/color/color.component' import { ColorComponent } from '../../common/input/color/color.component'
@@ -113,6 +113,7 @@ const sidebarItemLabels: Record<HideableSidebarItemID, string> = {
[HideableSidebarItemID.SavedViews]: $localize`Saved Views`, [HideableSidebarItemID.SavedViews]: $localize`Saved Views`,
[HideableSidebarItemID.ShareLinks]: $localize`Share Links`, [HideableSidebarItemID.ShareLinks]: $localize`Share Links`,
[HideableSidebarItemID.Workflows]: $localize`Workflows`, [HideableSidebarItemID.Workflows]: $localize`Workflows`,
[HideableSidebarItemID.OcrTemplates]: $localize`OCR Templates`,
[HideableSidebarItemID.Mail]: $localize`Mail`, [HideableSidebarItemID.Mail]: $localize`Mail`,
[HideableSidebarItemID.Documentation]: $localize`Documentation`, [HideableSidebarItemID.Documentation]: $localize`Documentation`,
} }
@@ -190,10 +191,8 @@ export class SettingsComponent
searchDbOnly: new FormControl(null), searchDbOnly: new FormControl(null),
searchLink: new FormControl(null), searchLink: new FormControl(null),
notificationsConsumerNewDocument: new FormControl(null),
notificationsConsumerSuccess: new FormControl(null), notificationsConsumerSuccess: new FormControl(null),
notificationsConsumerFailed: new FormControl(null), notificationsConsumerFailed: new FormControl(null),
notificationsConsumerSuppressOnDashboard: new FormControl(null),
savedViewsWarnOnUnsavedChange: new FormControl(null), savedViewsWarnOnUnsavedChange: new FormControl(null),
sidebarViewsShowCount: new FormControl(null), sidebarViewsShowCount: new FormControl(null),
@@ -371,18 +370,12 @@ export class SettingsComponent
updateCheckingEnabled: this.settings.get( updateCheckingEnabled: this.settings.get(
SETTINGS_KEYS.UPDATE_CHECKING_ENABLED SETTINGS_KEYS.UPDATE_CHECKING_ENABLED
), ),
notificationsConsumerNewDocument: this.settings.get(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT
),
notificationsConsumerSuccess: this.settings.get( notificationsConsumerSuccess: this.settings.get(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS
), ),
notificationsConsumerFailed: this.settings.get( notificationsConsumerFailed: this.settings.get(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED
), ),
notificationsConsumerSuppressOnDashboard: this.settings.get(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD
),
savedViewsWarnOnUnsavedChange: this.settings.get( savedViewsWarnOnUnsavedChange: this.settings.get(
SETTINGS_KEYS.SAVED_VIEWS_WARN_ON_UNSAVED_CHANGE SETTINGS_KEYS.SAVED_VIEWS_WARN_ON_UNSAVED_CHANGE
), ),
@@ -571,10 +564,6 @@ export class SettingsComponent
SETTINGS_KEYS.DATE_FORMAT, SETTINGS_KEYS.DATE_FORMAT,
this.settingsForm.value.dateFormat this.settingsForm.value.dateFormat
) )
this.settings.set(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT,
this.settingsForm.value.notificationsConsumerNewDocument
)
this.settings.set( this.settings.set(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS, SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS,
this.settingsForm.value.notificationsConsumerSuccess this.settingsForm.value.notificationsConsumerSuccess
@@ -583,10 +572,6 @@ export class SettingsComponent
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED, SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED,
this.settingsForm.value.notificationsConsumerFailed this.settingsForm.value.notificationsConsumerFailed
) )
this.settings.set(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD,
this.settingsForm.value.notificationsConsumerSuppressOnDashboard
)
this.settings.set( this.settings.set(
SETTINGS_KEYS.NOTES_ENABLED, SETTINGS_KEYS.NOTES_ENABLED,
this.settingsForm.value.notesEnabled this.settingsForm.value.notesEnabled
@@ -664,8 +649,11 @@ export class SettingsComponent
let savedToast: Toast = { let savedToast: Toast = {
content: $localize`Settings were saved successfully.`, content: $localize`Settings were saved successfully.`,
delay: 5000, delay: 5000,
level: ToastLevel.Success,
persistent: false,
} }
if (reloadRequired) { if (reloadRequired) {
savedToast.persistent = true
savedToast.content = $localize`Settings were saved successfully. Reload is required to apply some changes.` savedToast.content = $localize`Settings were saved successfully. Reload is required to apply some changes.`
savedToast.actionName = $localize`Reload now` savedToast.actionName = $localize`Reload now`
savedToast.action = () => { savedToast.action = () => {
@@ -99,11 +99,16 @@ describe('TrashComponent', () => {
modal.componentInstance.confirmClicked.next() modal.componentInstance.confirmClicked.next()
expect(toastErrorSpy).toHaveBeenCalled() expect(toastErrorSpy).toHaveBeenCalled()
let toasts
toastService.getToasts().subscribe((allToasts) => {
toasts = [...allToasts]
})
trashSpy.mockReturnValue(of('OK')) trashSpy.mockReturnValue(of('OK'))
component.delete(documentsInTrash[0]) component.delete(documentsInTrash[0])
expect(modal).toBeDefined() expect(modal).toBeDefined()
modal.componentInstance.confirmClicked.next() modal.componentInstance.confirmClicked.next()
expect(trashSpy).toHaveBeenCalled() expect(trashSpy).toHaveBeenCalled()
expect(toasts[0].content).toContain('deleted')
}) })
it('should support empty trash, show error if needed', () => { it('should support empty trash, show error if needed', () => {
@@ -11,7 +11,7 @@ import { takeUntil, tap } from 'rxjs'
import { Document } from 'src/app/data/document' import { Document } from 'src/app/data/document'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastLevel, ToastService } from 'src/app/services/toast.service'
import { TrashService } from 'src/app/services/trash.service' import { TrashService } from 'src/app/services/trash.service'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component' import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.component' import { PageHeaderComponent } from '../../common/page-header/page-header.component'
@@ -87,9 +87,11 @@ export class TrashComponent
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled.set(false)
this.trashService.emptyTrash([document.id]).subscribe({ this.trashService.emptyTrash([document.id]).subscribe({
next: () => { next: () => {
this.toastService.showInfo( this.toastService.show({
$localize`Document "${document.title}" deleted` content: $localize`Document "${document.title}" deleted`,
) delay: 5000,
level: ToastLevel.Success,
})
modal.close() modal.close()
this.reload() this.reload()
}, },
@@ -122,7 +124,11 @@ export class TrashComponent
.emptyTrash(documents ? Array.from(documents) : null) .emptyTrash(documents ? Array.from(documents) : null)
.subscribe({ .subscribe({
next: () => { next: () => {
this.toastService.showInfo($localize`Document(s) deleted`) this.toastService.show({
content: $localize`Document(s) deleted`,
delay: 5000,
level: ToastLevel.Success,
})
this.allToggled.set(false) this.allToggled.set(false)
modal.close() modal.close()
this.reload() this.reload()
@@ -144,6 +150,7 @@ export class TrashComponent
this.toastService.show({ this.toastService.show({
content: $localize`Document "${document.title}" restored`, content: $localize`Document "${document.title}" restored`,
delay: 5000, delay: 5000,
level: ToastLevel.Success,
actionName: $localize`Open document`, actionName: $localize`Open document`,
action: () => { action: () => {
this.router.navigate(['documents', document.id]) this.router.navigate(['documents', document.id])
@@ -165,7 +172,11 @@ export class TrashComponent
.restoreDocuments(documents ? Array.from(documents) : null) .restoreDocuments(documents ? Array.from(documents) : null)
.subscribe({ .subscribe({
next: () => { next: () => {
this.toastService.showInfo($localize`Document(s) restored`) this.toastService.show({
content: $localize`Document(s) restored`,
delay: 5000,
level: ToastLevel.Success,
})
this.allToggled.set(false) this.allToggled.set(false)
this.reload() this.reload()
}, },
@@ -101,7 +101,7 @@ describe('UsersAndGroupsComponent', () => {
component.editUser(users[0]) component.editUser(users[0])
const editDialog = modal.componentInstance as UserEditDialogComponent const editDialog = modal.componentInstance as UserEditDialogComponent
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
editDialog.failed.emit() editDialog.failed.emit()
expect(toastErrorSpy).toHaveBeenCalled() expect(toastErrorSpy).toHaveBeenCalled()
settingsService.currentUser.set(users[1]) // simulate logged in as different user settingsService.currentUser.set(users[1]) // simulate logged in as different user
@@ -120,7 +120,7 @@ describe('UsersAndGroupsComponent', () => {
const deleteDialog = modal.componentInstance as ConfirmDialogComponent const deleteDialog = modal.componentInstance as ConfirmDialogComponent
const deleteSpy = jest.spyOn(userService, 'delete') const deleteSpy = jest.spyOn(userService, 'delete')
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const listAllSpy = jest.spyOn(userService, 'listAll') const listAllSpy = jest.spyOn(userService, 'listAll')
deleteSpy.mockReturnValueOnce( deleteSpy.mockReturnValueOnce(
throwError(() => new Error('error deleting user')) throwError(() => new Error('error deleting user'))
@@ -186,7 +186,7 @@ describe('UsersAndGroupsComponent', () => {
component.editGroup(groups[0]) component.editGroup(groups[0])
const editDialog = modal.componentInstance as GroupEditDialogComponent const editDialog = modal.componentInstance as GroupEditDialogComponent
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
editDialog.failed.emit() editDialog.failed.emit()
expect(toastErrorSpy).toHaveBeenCalled() expect(toastErrorSpy).toHaveBeenCalled()
editDialog.succeeded.emit(groups[0]) editDialog.succeeded.emit(groups[0])
@@ -204,7 +204,7 @@ describe('UsersAndGroupsComponent', () => {
const deleteDialog = modal.componentInstance as ConfirmDialogComponent const deleteDialog = modal.componentInstance as ConfirmDialogComponent
const deleteSpy = jest.spyOn(groupService, 'delete') const deleteSpy = jest.spyOn(groupService, 'delete')
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const listAllSpy = jest.spyOn(groupService, 'listAll') const listAllSpy = jest.spyOn(groupService, 'listAll')
deleteSpy.mockReturnValueOnce( deleteSpy.mockReturnValueOnce(
throwError(() => new Error('error deleting group')) throwError(() => new Error('error deleting group'))
@@ -122,7 +122,7 @@ export class UsersAndGroupsComponent
) )
}, 2500) }, 2500)
} else { } else {
this.toastService.showInfo( this.toastService.showSuccess(
$localize`Saved user "${newUser.username}".` $localize`Saved user "${newUser.username}".`
) )
this.usersService.listAll().subscribe((r) => { this.usersService.listAll().subscribe((r) => {
@@ -151,7 +151,9 @@ export class UsersAndGroupsComponent
this.usersService.delete(user).subscribe({ this.usersService.delete(user).subscribe({
next: () => { next: () => {
modal.close() modal.close()
this.toastService.showInfo($localize`Deleted user "${user.username}"`) this.toastService.showSuccess(
$localize`Deleted user "${user.username}"`
)
this.usersService.listAll().subscribe((r) => { this.usersService.listAll().subscribe((r) => {
this.users.set(r.results) this.users.set(r.results)
}) })
@@ -178,7 +180,9 @@ export class UsersAndGroupsComponent
modal.componentInstance.succeeded modal.componentInstance.succeeded
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((newGroup) => { .subscribe((newGroup) => {
this.toastService.showInfo($localize`Saved group "${newGroup.name}".`) this.toastService.showSuccess(
$localize`Saved group "${newGroup.name}".`
)
this.groupsService.listAll().subscribe((r) => { this.groupsService.listAll().subscribe((r) => {
this.groups.set(r.results) this.groups.set(r.results)
}) })
@@ -204,7 +208,9 @@ export class UsersAndGroupsComponent
this.groupsService.delete(group).subscribe({ this.groupsService.delete(group).subscribe({
next: () => { next: () => {
modal.close() modal.close()
this.toastService.showInfo($localize`Deleted group "${group.name}"`) this.toastService.showSuccess(
$localize`Deleted group "${group.name}"`
)
this.groupsService.listAll().subscribe((r) => { this.groupsService.listAll().subscribe((r) => {
this.groups.set(r.results) this.groups.set(r.results)
}) })
@@ -29,8 +29,20 @@
</div> </div>
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 me-sm-auto order-3 order-sm-1" <div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 me-sm-auto order-3 order-sm-1"
[class.mobile-hidden]="mobileSearchHidden()"> [class.mobile-hidden]="mobileSearchHidden()">
<div class="col-12 header-search mx-auto"> <div class="col-12 header-search mx-auto d-flex align-items-stretch gap-3">
<pngx-global-search></pngx-global-search> <pngx-global-search class="flex-grow-1"></pngx-global-search>
<div *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Document }" class="btn-group header-upload-group flex-shrink-0">
<button class="btn btn-sm header-upload border-0 d-flex align-items-center justify-content-center py-0 px-2" type="button"
(click)="fileUpload.click()" aria-label="Upload documents" i18n-aria-label>
<i-bs class="d-flex" width="1.1em" height="1.1em" name="file-earmark-arrow-up"></i-bs><span class="ms-2 d-sm-none d-md-inline" i18n>Upload</span>
</button>
@if (uploadsInProgress > 0) {
<button class="btn btn-sm header-upload border-0 d-flex align-items-center py-0 px-2" type="button"
(click)="showUploadStatus()" aria-label="Show document processing status" i18n-aria-label>
<span class="spinner-border spinner-border-sm" aria-hidden="true"></span><span class="ms-2 small">{{ uploadsInProgress }}</span>
</button>
}
</div>
</div> </div>
</div> </div>
<ul ngbNav class="order-sm-3"> <ul ngbNav class="order-sm-3">
@@ -70,6 +82,9 @@
</ul> </ul>
</nav> </nav>
<input type="file" class="visually-hidden" id="navbar-file-upload" (change)="onFileSelected($event)" multiple #fileUpload tabindex="-1" aria-hidden="true">
<label for="navbar-file-upload" class="visually-hidden" i18n>Upload Files</label>
<div class="container-fluid"> <div class="container-fluid">
<div class="row"> <div class="row">
<nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse" <nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse"
@@ -274,6 +289,17 @@
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Workflows" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Workflows, $event)"></pngx-input-switch> <pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Workflows" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Workflows, $event)"></pngx-input-switch>
} }
</li> </li>
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.OcrTemplates) && !settingsService.organizingSidebarItems()"
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.OcrTemplate }">
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.OcrTemplates)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="ocr-templates" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="OCR Templates" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="bounding-box-circles"></i-bs><span class="nav-link-label"><ng-container i18n>OCR Templates</ng-container></span>
</a>
@if (settingsService.organizingSidebarItems()) {
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="OCR Templates" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.OcrTemplates)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.OcrTemplates, $event)"></pngx-input-switch>
}
</li>
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail) && !settingsService.organizingSidebarItems()" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }" <li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail) && !settingsService.organizingSidebarItems()" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
tourAnchor="tour.mail"> tourAnchor="tour.mail">
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail" <a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
@@ -419,5 +445,6 @@
[ngClass]="slimSidebarEnabled ? 'col-slim' : 'col-sidebar-expanded'"> [ngClass]="slimSidebarEnabled ? 'col-slim' : 'col-sidebar-expanded'">
<router-outlet></router-outlet> <router-outlet></router-outlet>
</main> </main>
<pngx-upload-status-tray></pngx-upload-status-tray>
</div> </div>
</div> </div>
@@ -538,7 +538,36 @@ main {
.header-search { .header-search {
width: 100%; width: 100%;
max-width: 55rem; max-width: 50rem;
}
.header-search pngx-global-search {
min-width: 0;
}
// light w shadow to stand out against search
.header-upload {
--bs-btn-bg: rgba(255, 255, 255, .15);
--bs-btn-hover-bg: rgba(255, 255, 255, .22);
--bs-btn-active-bg: rgba(255, 255, 255, .3);
color: var(--pngx-primary-text-contrast) !important; // .navbar .dropdown > .btn in styles.scss
& + & {
border-left: 1px solid rgba(255, 255, 255, .2) !important;
}
&:focus {
box-shadow: none;
}
&:focus-visible {
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, .5);
}
}
.header-upload-group {
border-radius: var(--bs-border-radius-sm);
box-shadow: 0 1px 3px rgba(0, 0, 0, .35), 0 1px 1px rgba(0, 0, 0, .2);
} }
@media screen and (max-width: 575.98px) { @media screen and (max-width: 575.98px) {
@@ -630,8 +659,7 @@ main {
} }
.dropdown-toggle::after { .dropdown-toggle::after {
margin-left: 0.4em; display: none;
vertical-align: 0.155em;
} }
.navbar .dropdown-menu { .navbar .dropdown-menu {
@@ -668,3 +696,7 @@ main {
::ng-deep .navItemDrag .position-absolute i-bs { ::ng-deep .navItemDrag .position-absolute i-bs {
display: none; display: none;
} }
.smaller {
font-size: 0.75rem;
}
@@ -6,7 +6,7 @@ import {
} from '@angular/common/http/testing' } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing' import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { BrowserModule } from '@angular/platform-browser' import { BrowserModule, By } from '@angular/platform-browser'
import { ActivatedRoute, Router } from '@angular/router' import { ActivatedRoute, Router } from '@angular/router'
import { RouterTestingModule } from '@angular/router/testing' import { RouterTestingModule } from '@angular/router/testing'
import { NgbModal, NgbModalModule, NgbModule } from '@ng-bootstrap/ng-bootstrap' import { NgbModal, NgbModalModule, NgbModule } from '@ng-bootstrap/ng-bootstrap'
@@ -33,8 +33,11 @@ import { SearchService } from 'src/app/services/rest/search.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { TasksService } from 'src/app/services/tasks.service' import { TasksService } from 'src/app/services/tasks.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component' import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
import { UploadStatusTrayComponent } from '../common/upload-status-tray/upload-status-tray.component'
import { DocumentDetailComponent } from '../document-detail/document-detail.component' import { DocumentDetailComponent } from '../document-detail/document-detail.component'
import { AppFrameComponent } from './app-frame.component' import { AppFrameComponent } from './app-frame.component'
import { GlobalSearchComponent } from './global-search/global-search.component' import { GlobalSearchComponent } from './global-search/global-search.component'
@@ -292,6 +295,7 @@ describe('AppFrameComponent', () => {
HideableSidebarItemID.Dashboard, HideableSidebarItemID.Dashboard,
HideableSidebarItemID.Workflows, HideableSidebarItemID.Workflows,
HideableSidebarItemID.ShareLinks, HideableSidebarItemID.ShareLinks,
HideableSidebarItemID.OcrTemplates,
]) ])
fixture.detectChanges() fixture.detectChanges()
@@ -307,6 +311,10 @@ describe('AppFrameComponent', () => {
fixture.nativeElement.querySelector('[routerLink="share-links"]') fixture.nativeElement.querySelector('[routerLink="share-links"]')
.parentElement.classList .parentElement.classList
).toContain('d-none') ).toContain('d-none')
expect(
fixture.nativeElement.querySelector('[routerLink="ocr-templates"]')
.parentElement.classList
).toContain('d-none')
expect( expect(
fixture.nativeElement.querySelector('[routerLink="mail"]').parentElement fixture.nativeElement.querySelector('[routerLink="mail"]').parentElement
.classList .classList
@@ -319,12 +327,21 @@ describe('AppFrameComponent', () => {
]) ])
settingsService.sidebarHiddenItemsEditing.set([ settingsService.sidebarHiddenItemsEditing.set([
HideableSidebarItemID.Dashboard, HideableSidebarItemID.Dashboard,
HideableSidebarItemID.OcrTemplates,
]) ])
fixture.detectChanges() fixture.detectChanges()
expect( expect(
fixture.nativeElement.querySelectorAll('pngx-input-switch').length fixture.nativeElement.querySelectorAll('pngx-input-switch').length
).toBe(6) ).toBe(7)
const ocrTemplatesLink = fixture.nativeElement.querySelector(
'[routerLink="ocr-templates"]'
)
expect(ocrTemplatesLink.parentElement.classList).not.toContain('d-none')
expect(ocrTemplatesLink.classList).toContain('opacity-50')
expect(
ocrTemplatesLink.parentElement.querySelector('pngx-input-switch')
).not.toBeNull()
expect( expect(
fixture.nativeElement.querySelector('[routerLink="dashboard"]') fixture.nativeElement.querySelector('[routerLink="dashboard"]')
.parentElement.classList .parentElement.classList
@@ -462,6 +479,45 @@ describe('AppFrameComponent', () => {
expect(routerSpy).toHaveBeenCalled() expect(routerSpy).toHaveBeenCalled()
}) })
it('should upload files selected from the navbar upload button', () => {
const uploadDocumentsService = TestBed.inject(UploadDocumentsService)
const uploadSpy = jest
.spyOn(uploadDocumentsService, 'uploadFiles')
.mockImplementation(() => {})
const toastSpy = jest.spyOn(toastService, 'showInfo')
const file = new File(['testing'], 'file.pdf')
const input = window.document.createElement('input')
jest.spyOn(input, 'files', 'get').mockReturnValue([file] as any)
component.onFileSelected({ target: input } as unknown as Event)
expect(toastSpy).not.toHaveBeenCalled()
expect(uploadSpy).toHaveBeenCalledWith([file])
})
it('should show in-progress uploads and expand the status tray', () => {
const websocketStatusService = TestBed.inject(WebsocketStatusService)
expect(component.uploadsInProgress).toEqual(0)
websocketStatusService.newFileUpload('file.pdf')
expect(component.uploadsInProgress).toEqual(1)
fixture.detectChanges()
const tray = fixture.debugElement.query(
By.directive(UploadStatusTrayComponent)
).componentInstance as UploadStatusTrayComponent
tray.toggleCollapsed()
expect(tray.collapsed()).toBe(true)
component.showUploadStatus()
expect(tray.collapsed()).toBe(false)
})
it('should not upload when no files are selected from the navbar', () => {
const uploadDocumentsService = TestBed.inject(UploadDocumentsService)
const uploadSpy = jest.spyOn(uploadDocumentsService, 'uploadFiles')
const input = window.document.createElement('input')
component.onFileSelected({ target: input } as unknown as Event)
expect(uploadSpy).not.toHaveBeenCalled()
})
it('should close all documents on logout', () => { it('should close all documents on logout', () => {
const closeAllSpy = jest.spyOn(openDocumentsService, 'closeAll') const closeAllSpy = jest.spyOn(openDocumentsService, 'closeAll')
component.onLogout() component.onLogout()
@@ -483,7 +539,7 @@ describe('AppFrameComponent', () => {
it('should update saved view sorting on drag + drop, show info', () => { it('should update saved view sorting on drag + drop, show info', () => {
const settingsSpy = jest.spyOn(settingsService, 'updateSidebarViewsSort') const settingsSpy = jest.spyOn(settingsService, 'updateSidebarViewsSort')
const toastSpy = jest.spyOn(toastService, 'showInfo') const toastSpy = jest.spyOn(toastService, 'showSuccess')
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true)) jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
component.onDrop({ previousIndex: 0, currentIndex: 1 } as CdkDragDrop< component.onDrop({ previousIndex: 0, currentIndex: 1 } as CdkDragDrop<
SavedView[] SavedView[]
@@ -525,6 +581,7 @@ describe('AppFrameComponent', () => {
it('should show toasts for django messages', () => { it('should show toasts for django messages', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
jest.spyOn(messagesService, 'get').mockReturnValue([ jest.spyOn(messagesService, 'get').mockReturnValue([
{ level: DjangoMessageLevel.WARNING, message: 'Test warning' }, { level: DjangoMessageLevel.WARNING, message: 'Test warning' },
@@ -535,7 +592,8 @@ describe('AppFrameComponent', () => {
]) ])
component.ngOnInit() component.ngOnInit()
expect(toastErrorSpy).toHaveBeenCalledTimes(2) expect(toastErrorSpy).toHaveBeenCalledTimes(2)
expect(toastInfoSpy).toHaveBeenCalledTimes(3) expect(toastSuccessSpy).toHaveBeenCalledTimes(1)
expect(toastInfoSpy).toHaveBeenCalledTimes(2)
}) })
it('should call maybeRefreshDocumentCounts after saved views reload', () => { it('should call maybeRefreshDocumentCounts after saved views reload', () => {
@@ -6,7 +6,14 @@ import {
moveItemInArray, moveItemInArray,
} from '@angular/cdk/drag-drop' } from '@angular/cdk/drag-drop'
import { NgClass } from '@angular/common' import { NgClass } from '@angular/common'
import { Component, HostListener, inject, OnInit, signal } from '@angular/core' import {
Component,
HostListener,
inject,
OnInit,
signal,
viewChild,
} from '@angular/core'
import { FormsModule } from '@angular/forms' import { FormsModule } from '@angular/forms'
import { ActivatedRoute, Router, RouterModule } from '@angular/router' import { ActivatedRoute, Router, RouterModule } from '@angular/router'
import { import {
@@ -48,12 +55,15 @@ import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { TasksService } from 'src/app/services/tasks.service' import { TasksService } from 'src/app/services/tasks.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
import { ChatComponent } from '../chat/chat/chat.component' import { ChatComponent } from '../chat/chat/chat.component'
import { SwitchComponent } from '../common/input/switch/switch.component'
import { BrandMarkComponent } from '../common/logo/brand-mark/brand-mark.component' import { BrandMarkComponent } from '../common/logo/brand-mark/brand-mark.component'
import { LogoComponent } from '../common/logo/logo.component' import { LogoComponent } from '../common/logo/logo.component'
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component' import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
import { SwitchComponent } from '../common/input/switch/switch.component' import { UploadStatusTrayComponent } from '../common/upload-status-tray/upload-status-tray.component'
import { DocumentDetailComponent } from '../document-detail/document-detail.component' import { DocumentDetailComponent } from '../document-detail/document-detail.component'
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component' import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
import { GlobalSearchComponent } from './global-search/global-search.component' import { GlobalSearchComponent } from './global-search/global-search.component'
@@ -84,6 +94,7 @@ const SCROLL_THRESHOLD = 16
TourNgBootstrap, TourNgBootstrap,
FormsModule, FormsModule,
SwitchComponent, SwitchComponent,
UploadStatusTrayComponent,
], ],
}) })
export class AppFrameComponent export class AppFrameComponent
@@ -101,6 +112,10 @@ export class AppFrameComponent
private modalService = inject(NgbModal) private modalService = inject(NgbModal)
permissionsService = inject(PermissionsService) permissionsService = inject(PermissionsService)
private djangoMessagesService = inject(DjangoMessagesService) private djangoMessagesService = inject(DjangoMessagesService)
private uploadDocumentsService = inject(UploadDocumentsService)
private websocketStatusService = inject(WebsocketStatusService)
private readonly uploadStatusTray = viewChild(UploadStatusTrayComponent)
readonly appRemoteVersion = signal<AppRemoteVersion>(null) readonly appRemoteVersion = signal<AppRemoteVersion>(null)
readonly isMenuCollapsed = signal(true) readonly isMenuCollapsed = signal(true)
@@ -171,6 +186,8 @@ export class AppFrameComponent
this.toastService.showError(message.message) this.toastService.showError(message.message)
break break
case DjangoMessageLevel.SUCCESS: case DjangoMessageLevel.SUCCESS:
this.toastService.showSuccess(message.message)
break
case DjangoMessageLevel.INFO: case DjangoMessageLevel.INFO:
case DjangoMessageLevel.DEBUG: case DjangoMessageLevel.DEBUG:
this.toastService.showInfo(message.message) this.toastService.showInfo(message.message)
@@ -477,7 +494,7 @@ export class AppFrameComponent
this.settingsService.updateSidebarViewsSort(sidebarViews).subscribe({ this.settingsService.updateSidebarViewsSort(sidebarViews).subscribe({
next: () => { next: () => {
this.toastService.showInfo($localize`Sidebar views updated`) this.toastService.showSuccess($localize`Sidebar views updated`)
}, },
error: (e) => { error: (e) => {
this.toastService.showError($localize`Error updating sidebar views`, e) this.toastService.showError($localize`Error updating sidebar views`, e)
@@ -511,6 +528,22 @@ export class AppFrameComponent
} }
} }
onFileSelected(event: Event) {
const input = event.target as HTMLInputElement
const files = Array.from(input.files ?? [])
input.value = ''
if (!files.length) return
this.uploadDocumentsService.uploadFiles(files)
}
get uploadsInProgress(): number {
return this.websocketStatusService.getConsumerStatusNotCompleted().length
}
showUploadStatus() {
this.uploadStatusTray()?.expand()
}
onLogout() { onLogout() {
this.openDocumentsService.closeAll() this.openDocumentsService.closeAll()
} }
@@ -409,7 +409,7 @@ describe('GlobalSearchComponent', () => {
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
// fail first // fail first
editDialog.failed.emit({ error: 'error creating item' }) editDialog.failed.emit({ error: 'error creating item' })
@@ -460,7 +460,7 @@ describe('GlobalSearchComponent', () => {
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
// fail first // fail first
editDialog.failed.emit({ error: 'error creating item' }) editDialog.failed.emit({ error: 'error creating item' })
@@ -216,7 +216,7 @@ export class GlobalSearchComponent implements OnInit {
modalRef.componentInstance.dialogMode.set(EditDialogMode.EDIT) modalRef.componentInstance.dialogMode.set(EditDialogMode.EDIT)
modalRef.componentInstance.object = object modalRef.componentInstance.object = object
modalRef.componentInstance.succeeded.subscribe(() => { modalRef.componentInstance.succeeded.subscribe(() => {
this.toastService.showInfo($localize`Successfully updated object.`) this.toastService.showSuccess($localize`Successfully updated object.`)
}) })
modalRef.componentInstance.failed.subscribe((e) => { modalRef.componentInstance.failed.subscribe((e) => {
this.toastService.showError($localize`Error occurred saving object.`, e) this.toastService.showError($localize`Error occurred saving object.`, e)
@@ -254,7 +254,7 @@ export class GlobalSearchComponent implements OnInit {
modalRef.componentInstance.dialogMode.set(EditDialogMode.EDIT) modalRef.componentInstance.dialogMode.set(EditDialogMode.EDIT)
modalRef.componentInstance.object = object modalRef.componentInstance.object = object
modalRef.componentInstance.succeeded.subscribe(() => { modalRef.componentInstance.succeeded.subscribe(() => {
this.toastService.showInfo($localize`Successfully updated object.`) this.toastService.showSuccess($localize`Successfully updated object.`)
}) })
modalRef.componentInstance.failed.subscribe((e) => { modalRef.componentInstance.failed.subscribe((e) => {
this.toastService.showError($localize`Error occurred saving object.`, e) this.toastService.showError($localize`Error occurred saving object.`, e)
@@ -1,28 +1,31 @@
<li ngbDropdown class="nav-item position-relative" (openChange)="onOpenChange($event)"> <li ngbDropdown class="nav-item position-relative" (openChange)="onOpenChange($event)">
@if (toasts().length) { @if (unread().length) {
<span class="notification-count badge rounded-pill z-3 pe-none bg-secondary position-absolute">{{ toasts().length }}</span> <span class="notification-count badge rounded-pill z-3 pe-none position-absolute" [class.bg-danger]="hasUnreadError()" [class.bg-secondary]="!hasUnreadError()">{{ unread().length }}</span>
} }
<button class="btn navbar-action border-0" id="notificationsDropdown" ngbDropdownToggle aria-label="Notifications" i18n-aria-label> <button class="btn navbar-action border-0" id="notificationsDropdown" ngbDropdownToggle aria-label="Notifications" i18n-aria-label>
<i-bs width="1.3em" height="1.3em" name="bell"></i-bs> <i-bs width="1.3em" height="1.3em" name="bell"></i-bs>
</button> </button>
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="notificationsDropdown"> <div ngbDropdownMenu class="dropdown-menu-end shadow p-2" aria-labelledby="notificationsDropdown">
<div class="btn-toolbar align-items-center" role="toolbar"> <div class="d-flex align-items-center px-1 mb-2">
<h6 i18n>Notifications</h6> <h6 class="mb-0" i18n>Notifications</h6>
<div class="btn-group ms-auto"> @if (toasts().length) {
<button class="btn btn-sm btn-outline-secondary mb-2 ms-auto" <button class="btn btn-link btn-sm p-0 ms-auto text-body-secondary text-decoration-none clear-all"
(click)="toastService.clearToasts()" (click)="toastService.clearToasts()">
[disabled]="toasts().length === 0" <i-bs width="1em" height="1em" name="check2-all" class="me-1"></i-bs><ng-container i18n>Clear All</ng-container>
i18n>Clear All</button> </button>
</div> }
</div> </div>
@if (toasts().length === 0) { @if (toasts().length === 0) {
<p class="text-center mb-0 small text-muted"><em i18n>No notifications</em></p> <div class="text-center text-body-secondary small py-4">
} <i-bs width="1.5em" height="1.5em" name="bell" class="d-block mx-auto mb-2 opacity-50"></i-bs>
<div class="scroll-list"> <ng-container i18n>No notifications</ng-container>
@for (toast of toasts(); track toast.id) {
<pngx-toast [autohide]="false" [toast]="toast" (hidden)="onHidden(toast)" (closed)="toastService.closeToast(toast)"></pngx-toast>
}
</div> </div>
}
<div class="scroll-list d-flex flex-column gap-2">
@for (toast of toasts(); track toast.id) {
<pngx-toast [autohide]="false" [toast]="toast" [now]="now()" [isNew]="newIds().has(toast.id)" (closed)="toastService.closeToast(toast)" (actioned)="onActioned(toast)"></pngx-toast>
}
</div>
</div> </div>
</li> </li>
@@ -30,3 +30,7 @@
right: -3rem; right: -3rem;
} }
} }
.clear-all:hover {
color: var(--bs-body-color) !important;
}
@@ -3,7 +3,7 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing' import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { Subject } from 'rxjs' import { Subject } from 'rxjs'
import { Toast, ToastService } from 'src/app/services/toast.service' import { Toast, ToastLevel, ToastService } from 'src/app/services/toast.service'
import { ToastsDropdownComponent } from './toasts-dropdown.component' import { ToastsDropdownComponent } from './toasts-dropdown.component'
const toasts = [ const toasts = [
@@ -90,4 +90,73 @@ describe('ToastsDropdownComponent', () => {
component.onOpenChange(true) component.onOpenChange(true)
expect(spy).toHaveBeenCalledWith(true) expect(spy).toHaveBeenCalledWith(true)
}) })
it('should badge unread toasts, red when one is an error', () => {
toastsSubject.next([
{ id: 'a', content: 'Read', delay: 5000, read: true },
{ id: 'b', content: 'Unread', delay: 5000, read: false },
])
fixture.detectChanges()
let badge: HTMLElement = fixture.nativeElement.querySelector(
'.notification-count'
)
expect(badge.textContent.trim()).toEqual('1')
expect(badge.classList).toContain('bg-secondary')
toastsSubject.next([
{ id: 'c', content: 'Failed', delay: 5000, level: ToastLevel.Error },
])
fixture.detectChanges()
badge = fixture.nativeElement.querySelector('.notification-count')
expect(badge.classList).toContain('bg-danger')
toastsSubject.next([{ id: 'd', content: 'Seen', delay: 5000, read: true }])
fixture.detectChanges()
expect(
fixture.nativeElement.querySelector('.notification-count')
).toBeNull()
})
it('should mark all toasts read when opened', () => {
const markSpy = jest.spyOn(toastService, 'markAllRead')
component.onOpenChange(false)
expect(markSpy).not.toHaveBeenCalled()
component.onOpenChange(true)
expect(markSpy).toHaveBeenCalled()
})
it('should tick the clock only while open', () => {
jest.useFakeTimers()
component.onOpenChange(true)
const opened = component.now()
jest.advanceTimersByTime(30000)
const ticked = component.now()
expect(ticked).not.toBe(opened)
component.onOpenChange(false)
jest.advanceTimersByTime(60000)
expect(component.now()).toBe(ticked)
jest.useRealTimers()
})
it('should mark toasts that were unread when opened as new', () => {
toastsSubject.next([
{ id: 'a', content: 'Seen', delay: 5000, read: true },
{ id: 'b', content: 'Unseen', delay: 5000, read: false },
])
fixture.detectChanges()
component.onOpenChange(true)
expect([...component.newIds()]).toEqual(['b'])
component.onOpenChange(false)
expect(component.newIds().size).toEqual(0)
})
it('should clear the toast and close the menu when an action is used', () => {
const toast = { id: 'a', content: 'Moved', delay: 5000 }
const closeToastSpy = jest.spyOn(toastService, 'closeToast')
const closeSpy = jest.spyOn(component.dropdown, 'close')
component.onActioned(toast)
expect(closeToastSpy).toHaveBeenCalledWith(toast)
expect(closeSpy).toHaveBeenCalled()
})
}) })
@@ -1,12 +1,20 @@
import { Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { import {
Component,
DestroyRef,
ViewChild,
computed,
inject,
signal,
} from '@angular/core'
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'
import {
NgbDropdown,
NgbDropdownModule, NgbDropdownModule,
NgbProgressbarModule, NgbProgressbarModule,
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { map } from 'rxjs' import { Subscription, interval, map } from 'rxjs'
import { Toast, ToastService } from 'src/app/services/toast.service' import { Toast, ToastLevel, ToastService } from 'src/app/services/toast.service'
import { ToastComponent } from '../../common/toast/toast.component' import { ToastComponent } from '../../common/toast/toast.component'
@Component({ @Component({
@@ -22,13 +30,44 @@ import { ToastComponent } from '../../common/toast/toast.component'
}) })
export class ToastsDropdownComponent { export class ToastsDropdownComponent {
toastService = inject(ToastService) toastService = inject(ToastService)
private destroyRef = inject(DestroyRef)
private clock: Subscription
@ViewChild(NgbDropdown) dropdown: NgbDropdown
/** Drives the relative timestamps, ticks only while the dropdown is open */
readonly now = signal(new Date())
/** Notifications that were unread when the dropdown was opened */
readonly newIds = signal(new Set<string>())
readonly toasts = toSignal( readonly toasts = toSignal(
this.toastService.getToasts().pipe(map((toasts) => [...toasts])), this.toastService.getToasts().pipe(map((toasts) => [...toasts])),
{ initialValue: [] as Toast[] } { initialValue: [] as Toast[] }
) )
readonly unread = computed(() => this.toasts().filter((t) => !t.read))
readonly hasUnreadError = computed(() =>
this.unread().some((t) => t.level === ToastLevel.Error)
)
onOpenChange(open: boolean): void { onOpenChange(open: boolean): void {
this.toastService.suppressPopupToasts = open this.toastService.suppressPopupToasts = open
this.clock?.unsubscribe()
this.newIds.set(new Set(open ? this.unread().map((t) => t.id) : []))
if (open) {
this.toastService.markAllRead()
this.now.set(new Date())
this.clock = interval(30000)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.now.set(new Date()))
}
}
/** Using an action (Undo, Open document...) clears it and closes the menu */
onActioned(toast: Toast): void {
this.toastService.closeToast(toast)
this.dropdown?.close()
} }
} }
@@ -1,49 +1,85 @@
<li ngbDropdown class="nav-item" (openChange)="onOpenChange($event)"> <li ngbDropdown class="nav-item" display="dynamic" placement="bottom-end" [popperOptions]="popperOptions" (openChange)="onOpenChange($event)">
<button class="btn navbar-action border-0" id="chatDropdown" ngbDropdownToggle aria-label="Chat" i18n-aria-label> <button class="btn navbar-action border-0" id="chatDropdown" ngbDropdownToggle aria-label="Chat" i18n-aria-label>
<i-bs width="1.3em" height="1.3em" name="chatSquareDots"></i-bs> <i-bs width="1.3em" height="1.3em" name="chatSquareDots"></i-bs>
</button> </button>
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown"> <div ngbDropdownMenu class="dropdown-menu-end shadow p-0 overflow-hidden" aria-labelledby="chatDropdown">
<div class="chat-container bg-light p-2"> <div class="d-flex align-items-center border-bottom px-3 py-2">
<div class="chat-messages font-monospace small"> <i-bs width="1em" height="1em" name="stars" class="text-primary me-2"></i-bs>
@for (message of messages(); track message) { <span class="fw-semibold">
<div class="message d-flex flex-row small" [class.justify-content-end]="message.role === 'user'"> @if (documentId()) {
<div class="p-2 m-2" [class.bg-body]="message.role === 'user'"> <ng-container i18n>Ask about this document</ng-container>
<span class="text-break"> } @else {
{{ message.content }} <ng-container i18n>Ask about your documents</ng-container>
@if (message.isStreaming) { <span class="blinking-cursor">|</span> }
</span>
@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 #scrollAnchor></div> </span>
</div> @if (messages().length) {
<button class="btn btn-sm btn-link text-muted ms-auto p-0" type="button" (click)="clearChat()" title="Clear chat" i18n-title>
<i-bs width="1em" height="1em" name="arrow-counterclockwise"></i-bs>
</button>
}
</div>
<form class="chat-input"> <div class="chat-messages px-2">
<label class="visually-hidden" for="chatInput" i18n>Message</label> @if (!messages().length) {
<div class="input-group"> <p class="text-muted text-center py-4 px-3 mb-0" i18n>Answers come from your documents, with links to their sources.</p>
<input }
#chatInput @for (message of messages(); track message) {
id="chatInput" <div class="message d-flex flex-row" [class.justify-content-end]="message.role === 'user'" [class.mt-3]="message.role === 'user' && !$first">
class="form-control form-control-sm" name="chatInput" type="text" <div class="p-2 m-2" [class.bg-body-secondary]="message.role === 'user'" [class.rounded-3]="message.role === 'user'" [class.flex-grow-1]="message.role === 'assistant'">
@if (message.isStreaming && !message.content) {
<div class="d-flex align-items-center text-muted">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
@if (slowResponse()) {
<ng-container i18n>Still thinking...</ng-container>
} @else {
<ng-container i18n>Thinking...</ng-container>
}
</div>
} @else if (message.role === 'assistant') {
<div class="chat-markdown text-break text-wrap" [class.streaming]="message.isStreaming" [innerHTML]="message.content | markdown"></div>
} @else {
<span class="text-break">{{ message.content }}</span>
}
@if (message.role === 'assistant' && !message.isStreaming && message.references?.length) {
<div class="chat-references list-group mt-3">
<div class="list-group-item text-muted small py-1" i18n>Sources</div>
@for (reference of message.references; track reference.id) {
<a class="list-group-item list-group-item-action d-flex align-items-center text-primary" [routerLink]="['/documents', reference.id]" [title]="reference.title">
<i-bs width="0.9em" height="0.9em" name="file-text" class="me-2 flex-shrink-0"></i-bs><span class="text-truncate">{{ reference.title }}</span>
</a>
}
</div>
}
</div>
</div>
}
<div #scrollAnchor></div>
</div>
<form class="chat-input border-top p-2">
<label class="visually-hidden" for="chatInput" i18n>Message</label>
<div class="input-group">
<input
#chatInput
id="chatInput"
class="form-control" name="chatInput" type="text"
[placeholder]="placeholder" [placeholder]="placeholder"
[disabled]="loading()" [disabled]="loading()"
[ngModel]="input()" [ngModel]="input()"
(ngModelChange)="input.set($event)" (ngModelChange)="input.set($event)"
(keydown)="searchInputKeyDown($event)" (keydown)="searchInputKeyDown($event)"
/> />
<button class="btn btn-sm btn-secondary" type="button" (click)="sendMessage()" [disabled]="loading()">Send</button> @if (loading()) {
</div> <button class="btn btn-outline-secondary" type="button" (click)="stopStreaming()" title="Stop" i18n-title aria-label="Stop" i18n-aria-label>
</form> <i-bs width="1em" height="1em" name="x-lg"></i-bs>
</div> </button>
} @else {
<button class="btn btn-primary" type="button" (click)="sendMessage()" title="Send" i18n-title aria-label="Send" i18n-aria-label>
<i-bs width="1em" height="1em" name="arrow-return-left"></i-bs>
</button>
}
</div>
</form>
</div> </div>
</li> </li>
@@ -1,17 +1,13 @@
.dropdown-menu { .dropdown-menu {
width: var(--pngx-toast-max-width); width: min(36rem, calc(100vw - 20px));
} }
.chat-messages { .chat-messages {
max-height: 350px; max-height: min(60dvh, 36rem);
overflow-y: auto; overflow-y: auto;
white-space: pre-wrap; white-space: pre-wrap;
} }
.chat-references {
font-family: var(--bs-font-sans-serif);
}
.dropdown-toggle::after { .dropdown-toggle::after {
display: none; display: none;
} }
@@ -20,23 +16,44 @@
white-space: initial; white-space: initial;
} }
@media screen and (max-width: 400px) { @media screen and (max-width: 575.98px) {
:host ::ng-deep .dropdown-menu-end { .chat-messages {
right: -3rem; max-height: calc(100dvh - 12rem);
} }
} }
.blinking-cursor { .chat-markdown.streaming ::ng-deep {
font-weight: bold; > :last-child:not(ul, ol, table)::after,
font-size: 1.2em; > :is(ul, ol):last-child > li:last-child::after {
animation: blink 1s step-end infinite; content: '';
display: inline-block;
width: 2px;
height: 1.05em;
margin-left: 2px;
background-color: currentColor;
vertical-align: -0.15em;
animation: blink 1s step-end infinite;
}
} }
@keyframes blink { @keyframes blink {
from, to { 50% {
opacity: 0; opacity: 0;
} }
50% { }
opacity: 1;
.chat-markdown ::ng-deep {
h1, h2, h3, h4, h5, h6 {
font-size: 1em;
font-weight: bold;
}
th, td {
padding: 0.15rem 0.4rem;
border: 1px solid var(--bs-border-color);
}
> :last-child {
margin-bottom: 0;
} }
} }
@@ -80,8 +80,16 @@ describe('ChatComponent', () => {
expect(component.messages()[0].content).toBe('Hello') expect(component.messages()[0].content).toBe('Hello')
expect(component.loading()).toBe(true) expect(component.loading()).toBe(true)
fixture.detectChanges()
const messagesEl = fixture.nativeElement.querySelector('.chat-messages')
expect(messagesEl.querySelector('.spinner-border')).not.toBeNull()
expect(messagesEl.textContent).toContain('Thinking...')
mockStream$.next('Hi') mockStream$.next('Hi')
expect(component.messages()[1].content).toBe('H') expect(component.messages()[1].content).toBe('H')
fixture.detectChanges()
expect(messagesEl.querySelector('.spinner-border')).toBeNull()
expect(messagesEl.querySelector('.chat-markdown.streaming')).not.toBeNull()
mockStream$.next('Hi there') mockStream$.next('Hi there')
// advance time to process the typewriter effect // advance time to process the typewriter effect
await jest.runAllTimersAsync() await jest.runAllTimersAsync()
@@ -96,6 +104,8 @@ describe('ChatComponent', () => {
await fixture.whenStable() await fixture.whenStable()
expect(component.loading()).toBe(false) expect(component.loading()).toBe(false)
expect(component.messages()[1].isStreaming).toBe(false) expect(component.messages()[1].isStreaming).toBe(false)
fixture.detectChanges()
expect(messagesEl.querySelector('.chat-markdown.streaming')).toBeNull()
expect(fixture.nativeElement.querySelector('#chatInput').disabled).toBe( expect(fixture.nativeElement.querySelector('#chatInput').disabled).toBe(
false false
) )
@@ -123,6 +133,10 @@ describe('ChatComponent', () => {
mockStream$.next( mockStream$.next(
`Hi there${CHAT_METADATA_DELIMITER}{"references":[{"id":42,"title":"Bread Recipe"}]}` `Hi there${CHAT_METADATA_DELIMITER}{"references":[{"id":42,"title":"Bread Recipe"}]}`
) )
fixture.detectChanges()
expect(fixture.nativeElement.querySelector('.chat-references')).toBeNull()
mockStream$.complete()
jest.advanceTimersByTime(1000) jest.advanceTimersByTime(1000)
fixture.detectChanges() fixture.detectChanges()
@@ -156,13 +170,96 @@ describe('ChatComponent', () => {
component.input.set('Hello') component.input.set('Hello')
component.sendMessage() component.sendMessage()
mockStream$.next('Partial')
mockStream$.error('Error') mockStream$.error('Error')
expect(component.messages()[1].content).toContain( expect(component.messages()[1].content).toBe(
'⚠️ Error receiving response.' 'Partial\n\n⚠️ Error receiving response.'
) )
expect(component.loading()).toBe(false) expect(component.loading()).toBe(false)
}) })
it('should keep streaming until the typewriter catches up', () => {
component.input.set('Hello')
component.sendMessage()
mockStream$.next('Hi there')
mockStream$.complete()
expect(component.messages()[1].isStreaming).toBe(true)
expect(component.loading()).toBe(true)
jest.advanceTimersByTime(1000)
expect(component.messages()[1].content).toBe('Hi there')
expect(component.messages()[1].isStreaming).toBe(false)
expect(component.loading()).toBe(false)
})
it('should show a slow response message while waiting', () => {
component.input.set('Hello')
component.sendMessage()
fixture.detectChanges()
const messagesEl = fixture.nativeElement.querySelector('.chat-messages')
expect(messagesEl.textContent).not.toContain('Still thinking...')
jest.advanceTimersByTime(8000)
fixture.detectChanges()
expect(messagesEl.textContent).toContain('Still thinking...')
mockStream$.next('Hi')
expect(component.slowResponse()).toBe(false)
})
it('should stop streaming and keep the partial response', () => {
component.input.set('Hello')
component.sendMessage()
fixture.detectChanges()
const stopButton = fixture.nativeElement.querySelector('.chat-input button')
expect(stopButton.getAttribute('aria-label')).toBe('Stop')
mockStream$.next('Hi there')
jest.advanceTimersByTime(20)
stopButton.click()
expect(mockStream$.observed).toBe(false)
expect(component.messages()[1].content).toBe('Hi there')
expect(component.messages()[1].isStreaming).toBe(false)
expect(component.loading()).toBe(false)
jest.advanceTimersByTime(1000)
expect(component.messages()[1].content).toBe('Hi there')
})
it('should drop the empty response when stopped before any content', () => {
component.input.set('Hello')
component.sendMessage()
component.stopStreaming()
expect(component.messages()).toHaveLength(1)
expect(component.loading()).toBe(false)
component.stopStreaming() // no-op once stopped
expect(component.messages()).toHaveLength(1)
})
it('should clear the conversation', () => {
component.input.set('Hello')
component.sendMessage()
mockStream$.next('Hi there')
fixture.detectChanges()
fixture.nativeElement.querySelector('button[title="Clear chat"]').click()
expect(component.messages()).toHaveLength(0)
expect(component.loading()).toBe(false)
expect(mockStream$.observed).toBe(false)
fixture.detectChanges()
jest.advanceTimersByTime(10)
expect(document.activeElement).toBe(
fixture.nativeElement.querySelector('#chatInput')
)
expect(fixture.nativeElement.textContent).toContain(
'Answers come from your documents'
)
})
it('should enqueue typewriter chunks correctly', () => { it('should enqueue typewriter chunks correctly', () => {
const message = { content: '', role: 'assistant', isStreaming: true } const message = { content: '', role: 'assistant', isStreaming: true }
component.enqueueTypewriter(null, message as any) // coverage for null component.enqueueTypewriter(null, message as any) // coverage for null
@@ -207,4 +304,33 @@ describe('ChatComponent', () => {
component.searchInputKeyDown(event) component.searchInputKeyDown(event)
expect(component.sendMessage).not.toHaveBeenCalled() expect(component.sendMessage).not.toHaveBeenCalled()
}) })
it('should render markdown for assistant messages only', () => {
component.messages.set([
{ role: 'user', content: '**user**' },
{ role: 'assistant', content: '**assistant** <script>x</script>' },
])
fixture.detectChanges()
const el: HTMLElement = fixture.nativeElement
expect(el.querySelector('.chat-markdown strong')?.textContent).toBe(
'assistant'
)
expect(el.querySelector('.chat-markdown script')).toBeNull()
expect(el.textContent).toContain('**user**')
})
it('should keep the destination hostname visible after sanitizing chat links', () => {
component.messages.set([
{
role: 'assistant',
content:
'[View original document](https://otherdomain.example/?data=secret)',
},
])
fixture.detectChanges()
const el: HTMLElement = fixture.nativeElement
const link = el.querySelector('.chat-markdown a')
expect(link?.textContent).toBe('View original document')
expect(link?.nextElementSibling?.textContent).toBe('(otherdomain.example)')
})
}) })
@@ -10,12 +10,16 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NavigationEnd, Router, RouterModule } from '@angular/router' import { NavigationEnd, Router, RouterModule } from '@angular/router'
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap' import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { filter, map } from 'rxjs' import { filter, map, Subscription } from 'rxjs'
import { MarkdownPipe } from 'src/app/pipes/markdown.pipe'
import { import {
ChatMessage, ChatMessage,
ChatService, ChatService,
parseChatResponse, parseChatResponse,
} from 'src/app/services/chat.service' } from 'src/app/services/chat.service'
import { pngxPopperOptions } from 'src/app/utils/popper-options'
const SLOW_RESPONSE_DELAY_MS = 8000
@Component({ @Component({
selector: 'pngx-chat', selector: 'pngx-chat',
@@ -25,6 +29,7 @@ import {
RouterModule, RouterModule,
NgxBootstrapIconsModule, NgxBootstrapIconsModule,
NgbDropdownModule, NgbDropdownModule,
MarkdownPipe,
], ],
templateUrl: './chat.component.html', templateUrl: './chat.component.html',
styleUrl: './chat.component.scss', styleUrl: './chat.component.scss',
@@ -34,6 +39,8 @@ export class ChatComponent implements OnInit {
readonly loading = signal(false) readonly loading = signal(false)
readonly input = signal('') readonly input = signal('')
readonly documentId = signal<number>(undefined) readonly documentId = signal<number>(undefined)
readonly slowResponse = signal(false)
public popperOptions = pngxPopperOptions
private chatService: ChatService = inject(ChatService) private chatService: ChatService = inject(ChatService)
private router: Router = inject(Router) private router: Router = inject(Router)
@@ -43,6 +50,11 @@ export class ChatComponent implements OnInit {
private typewriterBuffer: string[] = [] private typewriterBuffer: string[] = []
private typewriterActive = false private typewriterActive = false
private typewriterTimeout: ReturnType<typeof setTimeout>
private streamSubscription: Subscription
private streamingMessage: ChatMessage
private streamFinished = false
private slowResponseTimeout: ReturnType<typeof setTimeout>
public get placeholder(): string { public get placeholder(): string {
return this.documentId() return this.documentId()
@@ -81,47 +93,96 @@ export class ChatComponent implements OnInit {
} }
this.messages.update((messages) => [...messages, assistantMessage]) this.messages.update((messages) => [...messages, assistantMessage])
this.loading.set(true) this.loading.set(true)
this.streamingMessage = assistantMessage
this.streamFinished = false
this.slowResponseTimeout = setTimeout(
() => this.slowResponse.set(true),
SLOW_RESPONSE_DELAY_MS
)
let lastVisibleContent = '' let lastVisibleContent = ''
this.chatService.streamChat(this.documentId(), this.input()).subscribe({ this.streamSubscription = this.chatService
next: (chunk) => { .streamChat(this.documentId(), this.input())
const nextResponse = parseChatResponse(chunk) .subscribe({
next: (chunk) => {
this.clearSlowResponse()
const nextResponse = parseChatResponse(chunk)
if (nextResponse.content.length < lastVisibleContent.length) { if (nextResponse.content.length < lastVisibleContent.length) {
this.resetTypewriter(assistantMessage, nextResponse.content) this.resetTypewriter(assistantMessage, nextResponse.content)
lastVisibleContent = nextResponse.content lastVisibleContent = nextResponse.content
} else { } else {
const visibleDelta = nextResponse.content.substring( const visibleDelta = nextResponse.content.substring(
lastVisibleContent.length lastVisibleContent.length
) )
lastVisibleContent = nextResponse.content lastVisibleContent = nextResponse.content
this.enqueueTypewriter(visibleDelta, assistantMessage) this.enqueueTypewriter(visibleDelta, assistantMessage)
} }
assistantMessage.references = nextResponse.references assistantMessage.references = nextResponse.references
this.notifyMessagesChanged() this.notifyMessagesChanged()
}, },
error: () => { error: () => {
assistantMessage.content += '\n\n⚠️ Error receiving response.' this.flushTypewriter(assistantMessage)
assistantMessage.isStreaming = false assistantMessage.content += '\n\n⚠️ Error receiving response.'
this.notifyMessagesChanged() this.finishStreaming(assistantMessage)
this.loading.set(false) },
}, complete: () => {
complete: () => { // let the typewriter catch up before hiding the cursor
assistantMessage.isStreaming = false this.streamFinished = true
this.notifyMessagesChanged() if (!this.typewriterActive) this.finishStreaming(assistantMessage)
this.loading.set(false) },
this.scrollToBottom() })
},
})
this.input.set('') this.input.set('')
} }
private resetTypewriter(message: ChatMessage, content: string): void { stopStreaming(): void {
const message = this.streamingMessage
if (!message?.isStreaming) return
this.streamSubscription?.unsubscribe()
this.flushTypewriter(message)
if (!message.content) {
this.messages.update((messages) => messages.filter((m) => m !== message))
}
this.finishStreaming(message)
}
clearChat(): void {
this.stopStreaming()
this.messages.set([])
setTimeout(() => this.chatInput?.nativeElement.focus())
}
private finishStreaming(message: ChatMessage): void {
this.clearSlowResponse()
this.streamFinished = false
message.isStreaming = false
this.notifyMessagesChanged()
this.loading.set(false)
this.scrollToBottom()
}
private clearSlowResponse(): void {
clearTimeout(this.slowResponseTimeout)
this.slowResponse.set(false)
}
private flushTypewriter(message: ChatMessage): void {
message.content += this.typewriterBuffer.join('')
this.cancelTypewriter()
}
private cancelTypewriter(): void {
clearTimeout(this.typewriterTimeout)
this.typewriterBuffer = [] this.typewriterBuffer = []
this.typewriterActive = false this.typewriterActive = false
}
private resetTypewriter(message: ChatMessage, content: string): void {
this.cancelTypewriter()
message.content = content message.content = content
this.notifyMessagesChanged() this.notifyMessagesChanged()
this.scrollToBottom() this.scrollToBottom()
@@ -141,6 +202,7 @@ export class ChatComponent implements OnInit {
playTypewriter(message: ChatMessage): void { playTypewriter(message: ChatMessage): void {
if (this.typewriterBuffer.length === 0) { if (this.typewriterBuffer.length === 0) {
this.typewriterActive = false this.typewriterActive = false
if (this.streamFinished) this.finishStreaming(message)
return return
} }
@@ -149,7 +211,8 @@ export class ChatComponent implements OnInit {
this.notifyMessagesChanged() this.notifyMessagesChanged()
this.scrollToBottom() this.scrollToBottom()
setTimeout(() => this.playTypewriter(message), 10) // 10ms per character // 10ms per character
this.typewriterTimeout = setTimeout(() => this.playTypewriter(message), 10)
} }
private notifyMessagesChanged(): void { private notifyMessagesChanged(): void {
@@ -0,0 +1,30 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
</button>
</div>
<div class="modal-body">
@if (messageBold) {
<p class="text-break"><b>{{messageBold}}</b></p>
}
@if (message) {
<p class="text-break" [innerHTML]="message"></p>
}
<pngx-input-select
[items]="templates()"
[(ngModel)]="template"
[allowNull]="true"
title="OCR template"
i18n-title
placeholder="All matching templates"
i18n-placeholder
></pngx-input-select>
</div>
<div class="modal-footer">
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled()">
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
</button>
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled()">
{{btnCaption}}
</button>
</div>
@@ -0,0 +1,52 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { of } from 'rxjs'
import { MATCH_NONE } from 'src/app/data/matching-model'
import { OcrTemplate } from 'src/app/data/ocr-template'
import { OcrTemplateService } from 'src/app/services/rest/ocr-template.service'
import { ZoneOcrConfirmDialogComponent } from './zone-ocr-confirm-dialog.component'
const templates: OcrTemplate[] = [
{
id: 1,
name: 'Invoices',
match: '',
matching_algorithm: MATCH_NONE,
sample_document: null,
source_width: 0,
source_height: 0,
enabled: true,
zones: [],
},
]
describe('ZoneOcrConfirmDialogComponent', () => {
let component: ZoneOcrConfirmDialogComponent
let fixture: ComponentFixture<ZoneOcrConfirmDialogComponent>
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
NgbActiveModal,
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
imports: [ZoneOcrConfirmDialogComponent],
})
jest
.spyOn(TestBed.inject(OcrTemplateService), 'listAll')
.mockReturnValue(of({ count: 1, all: [1], results: templates }))
fixture = TestBed.createComponent(ZoneOcrConfirmDialogComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should offer templates and default to all matching templates', () => {
expect(component.templates()).toEqual(templates)
expect(component.template).toBeNull()
expect(fixture.nativeElement.textContent).toContain('OCR template')
})
})
@@ -0,0 +1,31 @@
import { Component, OnInit, inject, signal } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { takeUntil } from 'rxjs'
import { OcrTemplate } from 'src/app/data/ocr-template'
import { OcrTemplateService } from 'src/app/services/rest/ocr-template.service'
import { SelectComponent } from '../../input/select/select.component'
import { ConfirmDialogComponent } from '../confirm-dialog.component'
@Component({
selector: 'pngx-zone-ocr-confirm-dialog',
templateUrl: './zone-ocr-confirm-dialog.component.html',
imports: [FormsModule, SelectComponent],
})
export class ZoneOcrConfirmDialogComponent
extends ConfirmDialogComponent
implements OnInit
{
private readonly ocrTemplateService = inject(OcrTemplateService)
public readonly templates = signal<OcrTemplate[]>([])
/** The template to run, or null for every template that matches. */
template: number | null = null
ngOnInit() {
this.ocrTemplateService
.listAll()
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((r) => this.templates.set(r.results))
}
}
@@ -110,7 +110,7 @@ describe('CustomFieldsDropdownComponent', () => {
let modal: NgbModalRef let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1])) modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const getFieldsSpy = jest.spyOn( const getFieldsSpy = jest.spyOn(
CustomFieldsDropdownComponent.prototype as any, CustomFieldsDropdownComponent.prototype as any,
'getFields' 'getFields'
@@ -129,7 +129,9 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio
modal.componentInstance.succeeded modal.componentInstance.succeeded
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((newField) => { .subscribe((newField) => {
this.toastService.showInfo($localize`Saved field "${newField.name}".`) this.toastService.showSuccess(
$localize`Saved field "${newField.name}".`
)
this.customFieldsService.clearCache() this.customFieldsService.clearCache()
this.getFields() this.getFields()
this.created.emit(newField) this.created.emit(newField)
@@ -15,7 +15,7 @@
</a> </a>
} }
</div> </div>
<div class="input-group input-group-sm small ps-1 pe-2"> <div class="input-group input-group-sm ps-1 pe-2">
<ng-select class="w-100" name="createdRelativeDate" <ng-select class="w-100" name="createdRelativeDate"
[items]="relativeDates" [(ngModel)]="createdRelativeDate" [items]="relativeDates" [(ngModel)]="createdRelativeDate"
bindValue="id" bindValue="id"
@@ -92,7 +92,7 @@
</a> </a>
} }
</div> </div>
<div class="input-group input-group-sm small ps-1 pe-2"> <div class="input-group input-group-sm ps-1 pe-2">
<ng-select class="w-100" name="addedRelativeDate" <ng-select class="w-100" name="addedRelativeDate"
[items]="relativeDates" [(ngModel)]="addedRelativeDate" [items]="relativeDates" [(ngModel)]="addedRelativeDate"
bindValue="id" bindValue="id"
@@ -151,7 +151,7 @@ describe('UserEditDialogComponent', () => {
'deactivateTotp' 'deactivateTotp'
) )
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
deactivateSpy.mockReturnValueOnce(throwError(() => new Error('error'))) deactivateSpy.mockReturnValueOnce(throwError(() => new Error('error')))
component.deactivateTotp() component.deactivateTotp()
expect(deactivateSpy).toHaveBeenCalled() expect(deactivateSpy).toHaveBeenCalled()
@@ -132,7 +132,7 @@ export class UserEditDialogComponent
next: (result) => { next: (result) => {
this.totpLoading.set(false) this.totpLoading.set(false)
if (result) { if (result) {
this.toastService.showInfo($localize`Totp deactivated`) this.toastService.showSuccess($localize`Totp deactivated`)
this.object.is_mfa_enabled = false this.object.is_mfa_enabled = false
} else { } else {
this.toastService.showError($localize`Totp deactivation failed`) this.toastService.showError($localize`Totp deactivation failed`)
@@ -209,6 +209,7 @@
@if (isTagsFilter(filter.get('type').value)) { @if (isTagsFilter(filter.get('type').value)) {
<pngx-input-tags <pngx-input-tags
[allowCreate]="false" [allowCreate]="false"
[preserveHierarchy]="false"
[title]="null" [title]="null"
formControlName="values" formControlName="values"
></pngx-input-tags> ></pngx-input-tags>
@@ -320,7 +321,7 @@
<h6 class="form-label" i18n>Remove tags</h6> <h6 class="form-label" i18n>Remove tags</h6>
<pngx-input-switch i18n-title title="Remove all" [horizontal]="true" formControlName="remove_all_tags"></pngx-input-switch> <pngx-input-switch i18n-title title="Remove all" [horizontal]="true" formControlName="remove_all_tags"></pngx-input-switch>
<div class="mt-n3"> <div class="mt-n3">
<pngx-input-tags [allowCreate]="false" title="" formControlName="remove_tags"></pngx-input-tags> <pngx-input-tags [allowCreate]="false" [preserveHierarchy]="false" title="" formControlName="remove_tags"></pngx-input-tags>
</div> </div>
<h6 class="form-label" i18n>Remove correspondents</h6> <h6 class="form-label" i18n>Remove correspondents</h6>
@@ -52,7 +52,7 @@ describe('EmailDocumentDialogComponent', () => {
it('should support sending single document via email, showing error if needed', () => { it('should support sending single document via email, showing error if needed', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastSuccessSpy = jest.spyOn(toastService, 'showInfo') const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
component.documentIds.set([1]) component.documentIds.set([1])
component.emailAddress = 'hello@paperless-ngx.com' component.emailAddress = 'hello@paperless-ngx.com'
component.emailSubject = 'Hello' component.emailSubject = 'Hello'
@@ -73,7 +73,7 @@ describe('EmailDocumentDialogComponent', () => {
it('should support sending multiple documents via email, showing appropriate messages', () => { it('should support sending multiple documents via email, showing appropriate messages', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastSuccessSpy = jest.spyOn(toastService, 'showInfo') const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
component.documentIds.set([1, 2, 3]) component.documentIds.set([1, 2, 3])
component.emailAddress = 'hello@paperless-ngx.com' component.emailAddress = 'hello@paperless-ngx.com'
component.emailSubject = 'Hello' component.emailSubject = 'Hello'
@@ -50,7 +50,7 @@ export class EmailDocumentDialogComponent extends LoadingComponentWithPermission
this.emailSubject = '' this.emailSubject = ''
this.emailMessage = '' this.emailMessage = ''
this.close() this.close()
this.toastService.showInfo($localize`Email sent`) this.toastService.showSuccess($localize`Email sent`)
}, },
error: (e) => { error: (e) => {
this.loading.set(false) this.loading.set(false)
@@ -51,20 +51,20 @@
@if (editing) { @if (editing) {
@if (filteredItems.length === 0 && createRef !== undefined && filterText?.length > 0) { @if (filteredItems.length === 0 && createRef !== undefined && filterText?.length > 0) {
<button class="list-group-item list-group-item-action bg-light" (click)="createClicked()" [disabled]="disabled"> <button class="list-group-item list-group-item-action bg-light" (click)="createClicked()" [disabled]="disabled">
<small class="ms-2"><ng-container i18n>Create</ng-container> "{{filterText}}"</small> <span class="ms-2"><ng-container i18n>Create</ng-container> "{{filterText}}"</span>
<i-bs width="1.5em" height="1em" name="plus"></i-bs> <i-bs width="1.5em" height="1em" name="plus"></i-bs>
</button> </button>
} }
@if (filteredItems.length > 0) { @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"> <button class="list-group-item list-group-item-action bg-light d-flex align-items-center" (click)="applyClicked()" [disabled]="!modelIsDirty() || disabled">
<small class="ms-2" [ngClass]="{'fw-bold': modelIsDirty()}" i18n>Apply</small> <span class="ms-2" [ngClass]="{'fw-bold': modelIsDirty()}" i18n>Apply</span>
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs> <i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
</button> </button>
} }
} }
@if (extraButtonTitle && (showExtraButtonIfEmpty || filteredItems?.length > 0)) { @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"> <button class="list-group-item list-group-item-action bg-light d-flex align-items-center" (click)="extraButtonClicked($event)" [disabled]="disabled">
<small class="ms-2 fw-bold">{{extraButtonTitle}}</small> <span class="ms-2 fw-bold">{{extraButtonTitle}}</span>
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs> <i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
</button> </button>
} }
@@ -29,7 +29,7 @@
line-height: 1; line-height: 1;
small { small {
font-size: 65%; font-size: 0.65rem;
} }
} }
@@ -23,7 +23,7 @@
@if (isTag) { @if (isTag) {
<pngx-tag [tag]="item" [clickable]="false"></pngx-tag> <pngx-tag [tag]="item" [clickable]="false"></pngx-tag>
} @else { } @else {
<small>{{item.name}}</small> {{item.name}}
} }
</div> </div>
</div> </div>
@@ -23,4 +23,9 @@
.badge { .badge {
flex-shrink: 0; flex-shrink: 0;
font-size: 0.75rem;
}
pngx-tag {
font-size: 1rem;
} }
@@ -40,7 +40,7 @@
(change)="onChange(selectedDocumentIDs)"> (change)="onChange(selectedDocumentIDs)">
<ng-template ng-label-tmp let-document="item"> <ng-template ng-label-tmp let-document="item">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
@if (!disabled) { @if (!disabled && multiple) {
<button class="btn p-0 lh-1" (click)="unselect(document)" (mousedown)="$event.stopImmediatePropagation()" type="button" title="Remove link" i18n-title><i-bs name="x"></i-bs></button> <button class="btn p-0 lh-1" (click)="unselect(document)" (mousedown)="$event.stopImmediatePropagation()" type="button" title="Remove link" i18n-title><i-bs name="x"></i-bs></button>
} }
@if (document.title) { @if (document.title) {
@@ -81,6 +81,9 @@ export class DocumentLinkComponent
@Input() @Input()
placeholder: string = $localize`Search for documents` placeholder: string = $localize`Search for documents`
@Input()
multiple: boolean = true
/** /**
* Parent for ng-select dropdown, needed to prevent close on click. * Parent for ng-select dropdown, needed to prevent close on click.
*/ */
@@ -1,5 +1,7 @@
@if (!accordion) { @if (!accordion) {
<h5 i18n>Permissions</h5> @if (title?.length > 0) {
<h5>{{title}}</h5>
}
<ng-container [ngTemplateOutlet]="permissionsForm"></ng-container> <ng-container [ngTemplateOutlet]="permissionsForm"></ng-container>
} }
@if (accordion) { @if (accordion) {
@@ -59,6 +59,9 @@ export class PermissionsFormComponent
@Input() @Input()
accordion: boolean = false accordion: boolean = false
@Input()
title: string = $localize`Permissions`
form = new FormGroup({ form = new FormGroup({
owner: new FormControl(null), owner: new FormControl(null),
set_permissions: new FormGroup({ set_permissions: new FormGroup({
@@ -222,6 +222,22 @@ describe('TagsComponent', () => {
expect(propagated).toEqual([2, 1]) expect(propagated).toEqual([2, 1])
}) })
it('should not add parents or remove children when not preserving hierarchy', () => {
const parent: Tag = { id: 1, name: 'parent' }
const child: Tag = { id: 2, name: 'child', parent: 1 }
parent.children = [child]
component.tags = [parent, child]
component.preserveHierarchy = false
component.value = []
component.addTag(2)
expect(component.value).toEqual([2])
component.value = [1, 2]
component.removeTag(1)
expect(component.value).toEqual([2])
})
it('should not duplicate parents when adding sibling nested tags', () => { it('should not duplicate parents when adding sibling nested tags', () => {
const root: Tag = { id: 1, name: 'root' } const root: Tag = { id: 1, name: 'root' }
const parent: Tag = { id: 2, name: 'parent', parent: 1 } const parent: Tag = { id: 2, name: 'parent', parent: 1 }
@@ -109,6 +109,9 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
@Input() @Input()
multiple: boolean = true multiple: boolean = true
@Input()
preserveHierarchy: boolean = true
@Output() @Output()
filterDocuments = new EventEmitter<Tag[]>() filterDocuments = new EventEmitter<Tag[]>()
@@ -148,7 +151,9 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
oldValue.splice(index, 1) oldValue.splice(index, 1)
// remove children // remove children
oldValue = this.removeChildren(oldValue, tag) if (this.preserveHierarchy) {
oldValue = this.removeChildren(oldValue, tag)
}
this.value = [...oldValue] this.value = [...oldValue]
this.onChange(this.value) this.onChange(this.value)
@@ -167,7 +172,7 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
} }
public onAdd(tag: Tag) { public onAdd(tag: Tag) {
if (tag?.parent) { if (this.preserveHierarchy && tag?.parent) {
// add all parents recursively // add all parents recursively
const parent = this.getTag(tag.parent) const parent = this.getTag(tag.parent)
if (parent && !this.value.includes(parent.id)) { if (parent && !this.value.includes(parent.id)) {
@@ -3,13 +3,13 @@
<h3 class="d-flex align-items-center mb-2 mb-md-1" style="line-height: 1.4"> <h3 class="d-flex align-items-center mb-2 mb-md-1" style="line-height: 1.4">
<span class="text-truncate">{{title()}}</span> <span class="text-truncate">{{title()}}</span>
@if (id()) { @if (id()) {
<span class="badge bg-primary text-primary-text-contrast ms-3 small fs-normal cursor-pointer" (click)="copyID()"> <button type="button" class="badge border-0 bg-primary text-primary-text-contrast ms-3 small fs-normal" (click)="copyID()" title="Copy ID" i18n-title>
@if (copied()) { @if (copied()) {
<i-bs width="1em" height="1em" name="clipboard-check" class="me-1"></i-bs><ng-container i18n>Copied!</ng-container> <i-bs width="1em" height="1em" name="clipboard-check" class="me-1"></i-bs><ng-container i18n>Copied!</ng-container>
} @else { } @else {
ID: {{id()}} ID: {{id()}}
} }
</span> </button>
} }
@if (subTitle()) { @if (subTitle()) {
<span class="page-subtitle h6 mb-0 mt-1 fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle()}}</span> <span class="page-subtitle h6 mb-0 mt-1 fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle()}}</span>
@@ -43,6 +43,13 @@ describe('PageHeaderComponent', () => {
expect(titleSpy).toHaveBeenCalledWith(`Foo Bar - ${environment.appTitle}`) expect(titleSpy).toHaveBeenCalledWith(`Foo Bar - ${environment.appTitle}`)
}) })
it('should set html title to just the app title until a title is available', () => {
const titleSpy = jest.spyOn(titleService, 'setTitle')
fixture.componentRef.setInput('title', undefined)
fixture.detectChanges()
expect(titleSpy).toHaveBeenCalledWith(environment.appTitle)
})
it('should copy id to clipboard, reset after 3 seconds', () => { it('should copy id to clipboard, reset after 3 seconds', () => {
jest.useFakeTimers() jest.useFakeTimers()
fixture.componentRef.setInput('id', 42) fixture.componentRef.setInput('id', 42)
@@ -27,7 +27,10 @@ export class PageHeaderComponent {
constructor() { constructor() {
effect(() => { effect(() => {
this.titleService.setTitle(`${this.title()} - ${environment.appTitle}`) const title = this.title()
this.titleService.setTitle(
title ? `${title} - ${environment.appTitle}` : environment.appTitle
)
}) })
} }
@@ -12,7 +12,7 @@
} }
</div> </div>
<div class="me-1"> <div class="me-1">
<small i18n>All</small> <span i18n>All</span>
</div> </div>
</button> </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"> <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>
<div class="me-1"> <div class="me-1">
<small>{{ownerFilterLabel}}</small> {{ownerFilterLabel}}
</div> </div>
</button> </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"> <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>
<div class="me-1"> <div class="me-1">
<small>{{ownerExclusionFilterLabel}}</small> {{ownerExclusionFilterLabel}}
</div> </div>
</button> </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"> <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>
<div class="me-1"> <div class="me-1">
<small>{{sharedByFilterLabel}}</small> {{sharedByFilterLabel}}
</div> </div>
</button> </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"> <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>
<div class="me-1"> <div class="me-1">
<small i18n>Unowned</small> <span i18n>Unowned</span>
</div> </div>
</button> </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"> <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"> <div class="me-1 w-100">
<ng-select <ng-select
name="user" name="user"
class="user-select small" class="user-select"
[ngModel]="selectionModel.includeUsers()" [ngModel]="selectionModel.includeUsers()"
(ngModelChange)="selectionModel.includeUsers.set($event)" (ngModelChange)="selectionModel.includeUsers.set($event)"
[disabled]="disabled" [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="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"> <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"> <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"><small i18n>Hide unowned</small></label> <label class="form-check-label w-100" for="hideUnowned"><ng-container i18n>Hide unowned</ng-container></label>
</div> </div>
</div> </div>
} }
@@ -100,7 +100,7 @@ describe('ProfileEditDialogComponent', () => {
expect(errorSpy).toHaveBeenCalled() expect(errorSpy).toHaveBeenCalled()
updateSpy.mockClear() updateSpy.mockClear()
const infoSpy = jest.spyOn(toastService, 'showInfo') const infoSpy = jest.spyOn(toastService, 'showSuccess')
component.form.patchValue(newProfile) component.form.patchValue(newProfile)
updateSpy.mockReturnValueOnce(of(newProfile)) updateSpy.mockReturnValueOnce(of(newProfile))
component.save() component.save()
@@ -373,7 +373,7 @@ describe('ProfileEditDialogComponent', () => {
it('should activate totp', () => { it('should activate totp', () => {
const activateSpy = jest.spyOn(profileService, 'activateTotp') const activateSpy = jest.spyOn(profileService, 'activateTotp')
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const error = new Error('failed to activate totp') const error = new Error('failed to activate totp')
activateSpy.mockReturnValueOnce(throwError(() => error)) activateSpy.mockReturnValueOnce(throwError(() => error))
component.totpSettings.set({ component.totpSettings.set({
@@ -405,7 +405,7 @@ describe('ProfileEditDialogComponent', () => {
it('should deactivate totp', () => { it('should deactivate totp', () => {
const deactivateSpy = jest.spyOn(profileService, 'deactivateTotp') const deactivateSpy = jest.spyOn(profileService, 'deactivateTotp')
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const error = new Error('failed to deactivate totp') const error = new Error('failed to deactivate totp')
deactivateSpy.mockReturnValueOnce(throwError(() => error)) deactivateSpy.mockReturnValueOnce(throwError(() => error))
component.deactivateTotp() component.deactivateTotp()
@@ -204,7 +204,7 @@ export class ProfileEditDialogComponent
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({
next: () => { next: () => {
this.toastService.showInfo($localize`Profile updated successfully`) this.toastService.showSuccess($localize`Profile updated successfully`)
if (passwordChanged) { if (passwordChanged) {
this.toastService.showInfo( this.toastService.showInfo(
$localize`Password has been changed, you will be logged out momentarily.` $localize`Password has been changed, you will be logged out momentarily.`
@@ -307,7 +307,9 @@ export class ProfileEditDialogComponent
this.recoveryCodes.set(activationResponse.recovery_codes) this.recoveryCodes.set(activationResponse.recovery_codes)
this.form.get('totp_code').enable() this.form.get('totp_code').enable()
if (activationResponse.success) { if (activationResponse.success) {
this.toastService.showInfo($localize`TOTP activated successfully`) this.toastService.showSuccess(
$localize`TOTP activated successfully`
)
} else { } else {
this.toastService.showError($localize`Error activating TOTP`) this.toastService.showError($localize`Error activating TOTP`)
} }
@@ -331,7 +333,9 @@ export class ProfileEditDialogComponent
this.isTotpEnabled.set(!success) this.isTotpEnabled.set(!success)
this.recoveryCodes.set(null) this.recoveryCodes.set(null)
if (success) { if (success) {
this.toastService.showInfo($localize`TOTP deactivated successfully`) this.toastService.showSuccess(
$localize`TOTP deactivated successfully`
)
} else { } else {
this.toastService.showError($localize`Error deactivating TOTP`) this.toastService.showError($localize`Error deactivating TOTP`)
} }
@@ -1,22 +1,33 @@
<ngb-toast <ngb-toast
[autohide]="autohide" [autohide]="false"
[delay]="toast.delay" [class.mb-2]="autohide"
[class]="toast.classname" [class.toast-list-item]="!autohide"
[class.mb-2]="true" [class.toast-new]="isNew"
[class.toast-success]="toast.level === ToastLevel.Success"
[class.toast-error]="toast.level === ToastLevel.Error"
(mouseenter)="setHovered(true)"
(mouseleave)="setHovered(false)"
(focusin)="setFocused(true)"
(focusout)="setFocused(false)"
(shown)="onShown(toast)" (shown)="onShown(toast)"
(hidden)="hidden.emit(toast)"> (hidden)="hidden.emit(toast)">
@if (autohide) { @if (autohide) {
<ngb-progressbar class="position-absolute h-100 w-100 top-90 start-0 bottom-0 end-0 pe-none" type="dark" [max]="toast.delay" [value]="toast.delayRemaining"></ngb-progressbar> <ngb-progressbar class="position-absolute w-100 start-0 bottom-0 pe-none" [max]="toast.delay" [value]="delayRemaining()"></ngb-progressbar>
<span class="visually-hidden">{{ toast.delayRemaining / 1000 | number: '1.0-0' }} seconds</span> <span class="visually-hidden">{{ delayRemaining() / 1000 | number: '1.0-0' }} seconds</span>
} }
<div class="d-flex align-items-top"> <div class="d-flex align-items-top">
@if (!toast.error) { @switch (toast.level) {
<i-bs width="0.9em" height="0.9em" name="info-circle"></i-bs> @case (ToastLevel.Success) {
<i-bs width="0.9em" height="0.9em" name="check-circle" class="toast-icon"></i-bs>
}
@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) { <div class="toast-content">
<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> <p class="ms-2 mb-0 text-break">{{toast.content}}</p>
@if (toast.error) { @if (toast.error) {
<details class="ms-2"> <details class="ms-2">
@@ -48,9 +59,12 @@
</details> </details>
} }
@if (toast.action) { @if (toast.action) {
<p class="mb-0 mt-2"><button class="btn btn-sm btn-outline-secondary" (click)="closed.emit(toast); toast.action()">{{toast.actionName}}</button></p> <button class="btn btn-sm toast-action ms-2 mt-2" (click)="actioned.emit(toast); toast.action()">{{toast.actionName}}</button>
} }
</div> </div>
@if (!autohide && toast.timestamp) {
<span class="toast-time small text-body-secondary text-nowrap ms-auto ps-2">{{relativeTimestamp}}</span>
}
<button type="button" class="btn-close ms-auto flex-shrink-0" data-bs-dismiss="toast" aria-label="Close" (click)="closed.emit(toast);"></button> <button type="button" class="btn-close ms-auto flex-shrink-0" data-bs-dismiss="toast" aria-label="Close" (click)="closed.emit(toast);"></button>
</div> </div>
</ngb-toast> </ngb-toast>
@@ -2,19 +2,105 @@
position: relative; position: relative;
} }
::ng-deep .toast.error { .toast {
border-color: hsla(350, 79%, 40%, 0.4); // bg-danger --pngx-toast-accent: var(--pngx-toast-info);
background-color: var(--pngx-toast-bg);
color: var(--bs-body-color);
overflow: hidden;
&.toast-success {
--pngx-toast-accent: var(--pngx-toast-success);
background-color: var(--pngx-toast-success-bg);
border-color: color-mix(in srgb, var(--pngx-toast-success) 30%, transparent);
}
&.toast-error {
--pngx-toast-accent: var(--pngx-toast-error);
background-color: var(--pngx-toast-error-bg);
border-color: color-mix(in srgb, var(--pngx-toast-error) 35%, transparent);
}
} }
::ng-deep .toast.error .toast-body { .toast-icon {
background-color: hsla(350, 79%, 40%, 0.8); // bg-danger color: var(--pngx-toast-accent);
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 { .progress {
background-color: var(--pngx-primary); height: 2px;
opacity: .07; background-color: transparent;
::ng-deep .progress-bar {
background-color: var(--bs-secondary-color);
opacity: .5;
}
} }
@@ -1,7 +1,11 @@
import { DatePipe } from '@angular/common'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing' import { ComponentFixture, TestBed } from '@angular/core/testing'
import { Clipboard } from '@angular/cdk/clipboard' import { Clipboard } from '@angular/cdk/clipboard'
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { ToastLevel } from 'src/app/services/toast.service'
import { ToastComponent } from './toast.component' import { ToastComponent } from './toast.component'
const toast1 = { const toast1 = {
@@ -30,6 +34,11 @@ describe('ToastComponent', () => {
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [ToastComponent, NgxBootstrapIconsModule.pick(allIcons)], imports: [ToastComponent, NgxBootstrapIconsModule.pick(allIcons)],
providers: [
DatePipe,
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
}).compileComponents() }).compileComponents()
fixture = TestBed.createComponent(ToastComponent) fixture = TestBed.createComponent(ToastComponent)
@@ -46,11 +55,117 @@ describe('ToastComponent', () => {
component.toast = toast2 component.toast = toast2
fixture.detectChanges() fixture.detectChanges()
component.onShown(toast2) component.onShown(toast2)
jest.advanceTimersByTime(5000) jest.advanceTimersByTime(5000 + 150) // ticks every 150ms
expect(component.toast.delayRemaining).toEqual(0) expect(component.delayRemaining()).toEqual(0)
jest.useRealTimers() jest.useRealTimers()
}) })
it('should start the progress bar full before the countdown begins', () => {
component.toast = { content: 'Info', delay: 3000 }
fixture.detectChanges()
expect(component.delayRemaining()).toEqual(3000)
})
it('should hide the toast when the countdown ends', () => {
jest.useFakeTimers()
const toast = { content: 'Info', delay: 3000 }
component.toast = toast
fixture.detectChanges()
const hideSpy = jest.spyOn(component.ngbToast, 'hide')
component.onShown(toast)
jest.advanceTimersByTime(2850)
expect(hideSpy).not.toHaveBeenCalled()
jest.advanceTimersByTime(300)
expect(hideSpy).toHaveBeenCalled()
jest.useRealTimers()
})
it('should pause the countdown while hovered', () => {
jest.useFakeTimers()
const toast = { content: 'Info', delay: 3000 }
component.toast = toast
fixture.detectChanges()
const hideSpy = jest.spyOn(component.ngbToast, 'hide')
const el: HTMLElement = fixture.nativeElement.querySelector('ngb-toast')
component.onShown(toast)
jest.advanceTimersByTime(1500)
el.dispatchEvent(new MouseEvent('mouseenter'))
const remaining = component.delayRemaining()
jest.advanceTimersByTime(10000)
expect(component.delayRemaining()).toEqual(remaining)
expect(hideSpy).not.toHaveBeenCalled()
el.dispatchEvent(new MouseEvent('mouseleave'))
jest.advanceTimersByTime(remaining + 150)
expect(hideSpy).toHaveBeenCalled()
jest.useRealTimers()
})
it('should pause the countdown while focus is inside', () => {
jest.useFakeTimers()
const toast = { content: 'Info', delay: 3000 }
component.toast = toast
fixture.detectChanges()
const el: HTMLElement = fixture.nativeElement.querySelector('ngb-toast')
component.onShown(toast)
el.dispatchEvent(new FocusEvent('focusin'))
// mouse leaving doesn't resume while focus is still inside
el.dispatchEvent(new MouseEvent('mouseenter'))
el.dispatchEvent(new MouseEvent('mouseleave'))
jest.advanceTimersByTime(5000)
expect(component.delayRemaining()).toEqual(3000)
el.dispatchEvent(new FocusEvent('focusout'))
jest.advanceTimersByTime(3000)
expect(component.delayRemaining()).toEqual(0)
jest.useRealTimers()
})
it('should not count down without autohide', () => {
jest.useFakeTimers()
const toast = { content: 'Info', delay: 3000 }
component.toast = toast
component.autohide = false
fixture.detectChanges()
component.onShown(toast)
jest.advanceTimersByTime(5000)
expect(component.delayRemaining()).toBeNull()
jest.useRealTimers()
})
it('should show an icon and colors for each level', () => {
component.toast = {
content: 'Saved',
delay: 5000,
level: ToastLevel.Success,
}
fixture.detectChanges()
const el: HTMLElement = fixture.nativeElement
expect(el.querySelector('i-bs[name="check-circle"]')).not.toBeNull()
expect(el.querySelector('ngb-toast').classList).not.toContain('toast-error')
fixture = TestBed.createComponent(ToastComponent)
fixture.componentInstance.toast = {
content: 'Failed',
delay: 5000,
level: ToastLevel.Error,
}
fixture.detectChanges()
const errorEl: HTMLElement = fixture.nativeElement
expect(
errorEl.querySelector('i-bs[name="exclamation-triangle"]')
).not.toBeNull()
expect(errorEl.querySelector('ngb-toast').classList).toContain(
'toast-error'
)
fixture = TestBed.createComponent(ToastComponent)
fixture.componentInstance.toast = { content: 'Started', delay: 5000 }
fixture.detectChanges()
expect(
fixture.nativeElement.querySelector('i-bs[name="info-circle"]')
).not.toBeNull()
})
it('should show an error if given with toast', () => { it('should show an error if given with toast', () => {
component.toast = toast1 component.toast = toast1
fixture.detectChanges() fixture.detectChanges()
@@ -88,4 +203,70 @@ describe('ToastComponent', () => {
component.getErrorText({ error: new Array(205).join('a') }) component.getErrorText({ error: new Array(205).join('a') })
).toContain('...') ).toContain('...')
}) })
it('should emit actioned, not closed, when the action is clicked', () => {
const action = jest.fn()
component.toast = {
content: 'Moved',
delay: 5000,
action,
actionName: 'Undo',
}
fixture.detectChanges()
const closedSpy = jest.spyOn(component.closed, 'emit')
const actionedSpy = jest.spyOn(component.actioned, 'emit')
const button: HTMLButtonElement = [
...fixture.nativeElement.querySelectorAll('button'),
].find((b) => b.textContent.includes('Undo'))
button.click()
expect(actionedSpy).toHaveBeenCalled()
expect(closedSpy).not.toHaveBeenCalled()
expect(action).toHaveBeenCalled()
})
it('should show a relative timestamp in the notifications list', () => {
component.toast = { content: 'Saved', delay: 5000, timestamp: new Date() }
component.autohide = false
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('Just now')
})
it('should update the relative timestamp as time passes', () => {
jest.useFakeTimers()
jest.setSystemTime(new Date('2026-10-19T12:00:00Z'))
component.toast = { content: 'Saved', delay: 5000, timestamp: new Date() }
component.autohide = false
fixture.componentRef.setInput('now', new Date())
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('Just now')
jest.setSystemTime(new Date('2026-10-19T12:03:00Z'))
fixture.componentRef.setInput('now', new Date())
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('3 minutes ago')
jest.useRealTimers()
})
it('should show a compact action link in the notifications list', () => {
const action = jest.fn()
component.toast = {
content: 'Moved',
delay: 5000,
action,
actionName: 'Undo',
timestamp: new Date(),
}
component.autohide = false
fixture.detectChanges()
const link: HTMLButtonElement =
fixture.nativeElement.querySelector('.toast-action')
expect(link.textContent).toContain('Undo')
expect(
fixture.nativeElement.querySelector('.btn-outline-secondary')
).toBeNull()
const actionedSpy = jest.spyOn(component.actioned, 'emit')
link.click()
expect(actionedSpy).toHaveBeenCalled()
expect(action).toHaveBeenCalled()
})
}) })
@@ -1,20 +1,26 @@
import { Clipboard } from '@angular/cdk/clipboard' import { Clipboard } from '@angular/cdk/clipboard'
import { DecimalPipe } from '@angular/common' import { DatePipe, DecimalPipe } from '@angular/common'
import { import {
Component, Component,
DestroyRef,
EventEmitter, EventEmitter,
Input, Input,
OnInit,
Output, Output,
ViewChild,
inject, inject,
signal, signal,
} from '@angular/core' } from '@angular/core'
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import { import {
NgbProgressbarModule, NgbProgressbarModule,
NgbToast,
NgbToastModule, NgbToastModule,
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { interval, take } from 'rxjs' import { Subscription, interval, takeWhile } from 'rxjs'
import { Toast } from 'src/app/services/toast.service' import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { Toast, ToastLevel } from 'src/app/services/toast.service'
@Component({ @Component({
selector: 'pngx-toast', selector: 'pngx-toast',
@@ -26,32 +32,79 @@ import { Toast } from 'src/app/services/toast.service'
], ],
templateUrl: './toast.component.html', templateUrl: './toast.component.html',
styleUrl: './toast.component.scss', styleUrl: './toast.component.scss',
providers: [DatePipe, CustomDatePipe],
}) })
export class ToastComponent { export class ToastComponent implements OnInit {
private clipboard = inject(Clipboard) private clipboard = inject(Clipboard)
private customDatePipe = inject(CustomDatePipe)
private destroyRef = inject(DestroyRef)
readonly copied = signal(false) readonly copied = signal(false)
readonly ToastLevel = ToastLevel
readonly delayRemaining = signal<number>(null)
@ViewChild(NgbToast) ngbToast: NgbToast
private hovered = false
private focused = false
private countdown: Subscription
@Input() toast: Toast @Input() toast: Toast
@Input() autohide: boolean = true @Input() autohide: boolean = true
/** Each new value re-renders the relative timestamp */
@Input() now: Date
/** Marks a notification that arrived since the dropdown was last opened */
@Input() isNew: boolean = false
@Output() hidden: EventEmitter<Toast> = new EventEmitter<Toast>() @Output() hidden: EventEmitter<Toast> = new EventEmitter<Toast>()
@Output() closed: EventEmitter<Toast> = new EventEmitter<Toast>() @Output() closed: EventEmitter<Toast> = new EventEmitter<Toast>()
/** The toast's action button was clicked */
@Output() actioned: EventEmitter<Toast> = new EventEmitter<Toast>()
ngOnInit(): void {
if (this.autohide) this.delayRemaining.set(this.toast.delay)
}
get relativeTimestamp(): string {
return this.customDatePipe.transform(this.toast.timestamp, 'relative')
}
get paused(): boolean {
return this.hovered || this.focused
}
setHovered(hovered: boolean) {
this.hovered = hovered
}
setFocused(focused: boolean) {
this.focused = focused
}
onShown(toast: Toast) { onShown(toast: Toast) {
if (!this.autohide) return if (!this.autohide) return
const refreshInterval = 150 const refreshInterval = 150
const delay = toast.delay - 500 // for fade animation this.delayRemaining.set(toast.delay)
interval(refreshInterval) this.countdown?.unsubscribe()
.pipe(take(Math.round(delay / refreshInterval))) this.countdown = interval(refreshInterval)
.subscribe((count) => { .pipe(
toast.delayRemaining = Math.max( takeWhile(() => this.delayRemaining() > 0),
0, takeUntilDestroyed(this.destroyRef)
delay - refreshInterval * (count + 1) )
.subscribe(() => {
if (this.paused) return
this.delayRemaining.update((remaining) =>
Math.max(0, remaining - refreshInterval)
) )
if (this.delayRemaining() === 0) {
this.ngbToast?.hide()
}
}) })
} }
@@ -1,3 +1,3 @@
@for (toast of toasts(); track toast.id) { @for (toast of toasts(); track toast.id) {
<pngx-toast [toast]="toast" [autohide]="true" (closed)="closeToast()"></pngx-toast> <pngx-toast [toast]="toast" [autohide]="true" (closed)="removePopup(toast)" (actioned)="closeToast(toast)" (hidden)="removePopup(toast)"></pngx-toast>
} }
@@ -1,10 +1,11 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing' import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing' import { ComponentFixture, TestBed } from '@angular/core/testing'
import { By } from '@angular/platform-browser'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { Subject } from 'rxjs' import { Subject } from 'rxjs'
import { Toast, ToastService } from 'src/app/services/toast.service' import { Toast, ToastService } from 'src/app/services/toast.service'
import { ToastsComponent } from './toasts.component' import { MAX_VISIBLE_TOASTS, ToastsComponent } from './toasts.component'
const toast = { const toast = {
content: 'Error 2 content', content: 'Error 2 content',
@@ -50,7 +51,7 @@ describe('ToastsComponent', () => {
it('should close toast', () => { it('should close toast', () => {
toastSubject.next(toast) toastSubject.next(toast)
const closeToastSpy = jest.spyOn(toastService, 'closeToast') const closeToastSpy = jest.spyOn(toastService, 'closeToast')
component.closeToast() component.closeToast(toast)
expect(component.toasts()).toEqual([]) expect(component.toasts()).toEqual([])
expect(closeToastSpy).toHaveBeenCalledWith(toast) expect(closeToastSpy).toHaveBeenCalledWith(toast)
}) })
@@ -59,4 +60,59 @@ describe('ToastsComponent', () => {
toastSubject.next(toast) toastSubject.next(toast)
expect(component.toasts()).toEqual([toast]) expect(component.toasts()).toEqual([toast])
}) })
it('should stack popups newest first, up to the maximum', () => {
const toasts: Toast[] = Array.from(
{ length: MAX_VISIBLE_TOASTS + 1 },
(_, i) => ({ id: `${i}`, content: `Toast ${i}`, delay: 5000 })
)
toasts.forEach((t) => toastSubject.next(t))
expect(component.toasts().map((t) => t.id)).toEqual(['3', '2', '1'])
// showing a visible toast again moves it to the top without duplicating
toastSubject.next(toasts[1])
expect(component.toasts().map((t) => t.id)).toEqual(['1', '3', '2'])
})
it('should remove a popup when it hides without closing it', () => {
const closeToastSpy = jest.spyOn(toastService, 'closeToast')
const first = { id: 'a', content: 'A', delay: 5000 }
const second = { id: 'b', content: 'B', delay: 5000 }
toastSubject.next(first)
toastSubject.next(second)
component.removePopup(first)
expect(component.toasts()).toEqual([second])
expect(closeToastSpy).not.toHaveBeenCalled()
})
it('should clear popups when toastService emits null', () => {
toastSubject.next(toast)
toastSubject.next(null)
expect(component.toasts()).toEqual([])
})
it('should render one toast component per popup', () => {
toastSubject.next({ id: 'a', content: 'A', delay: 5000 })
toastSubject.next({ id: 'b', content: 'B', delay: 5000 })
fixture.detectChanges()
expect(fixture.nativeElement.querySelectorAll('pngx-toast')).toHaveLength(2)
})
it('should only hide the popup on close, but clear it on action', () => {
const closeToastSpy = jest.spyOn(toastService, 'closeToast')
const first = { id: 'a', content: 'A', delay: 5000 }
const second = { id: 'b', content: 'B', delay: 5000 }
toastSubject.next(first)
toastSubject.next(second)
fixture.detectChanges()
const popups = fixture.debugElement.queryAll(By.css('pngx-toast'))
popups[1].triggerEventHandler('closed', first)
expect(closeToastSpy).not.toHaveBeenCalled()
expect(component.toasts()).toEqual([second])
popups[0].triggerEventHandler('actioned', second)
expect(closeToastSpy).toHaveBeenCalledWith(second)
expect(component.toasts()).toEqual([])
})
}) })
@@ -1,14 +1,18 @@
import { Component, inject } from '@angular/core' import { Component, inject, signal } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop' import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import { import {
NgbAccordionModule, NgbAccordionModule,
NgbProgressbarModule, NgbProgressbarModule,
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { map, merge, Subject } from 'rxjs'
import { Toast, ToastService } from 'src/app/services/toast.service' import { Toast, ToastService } from 'src/app/services/toast.service'
import { ToastComponent } from '../toast/toast.component' import { ToastComponent } from '../toast/toast.component'
/**
* Maximum number of popup toasts shown at once
*/
export const MAX_VISIBLE_TOASTS = 3
@Component({ @Component({
selector: 'pngx-toasts', selector: 'pngx-toasts',
templateUrl: './toasts.component.html', templateUrl: './toasts.component.html',
@@ -23,18 +27,31 @@ import { ToastComponent } from '../toast/toast.component'
export class ToastsComponent { export class ToastsComponent {
toastService = inject(ToastService) toastService = inject(ToastService)
private readonly closedToast = new Subject<void>() readonly toasts = signal<Toast[]>([])
readonly toasts = toSignal( constructor() {
merge( this.toastService.showToast
this.toastService.showToast.pipe(map((toast) => (toast ? [toast] : []))), .pipe(takeUntilDestroyed())
this.closedToast.pipe(map(() => [] as Toast[])) .subscribe((toast) => {
), if (!toast) {
{ initialValue: [] as Toast[] } this.toasts.set([])
) return
}
this.toasts.update((toasts) =>
[toast, ...toasts.filter((t) => t.id !== toast.id)].slice(
0,
MAX_VISIBLE_TOASTS
)
)
})
}
closeToast() { closeToast(toast: Toast) {
this.toastService.closeToast(this.toasts()[0]) this.toastService.closeToast(toast)
this.closedToast.next() this.removePopup(toast)
}
removePopup(toast: Toast) {
this.toasts.update((toasts) => toasts.filter((t) => t.id !== toast.id))
} }
} }
@@ -0,0 +1,52 @@
@if (canAddDocuments && getStatus().length > 0) {
<div class="fixed-bottom p-2 p-md-4 d-flex justify-content-end pe-none consumer-status-list" [ngClass]="slimSidebarEnabled ? 'col-slim' : 'offset-sidebar-expanded'">
<div class="col col-lg-4 col-xl-3 ps-0 pe-0 ps-lg-3 pe-lg-0 pe-auto overflow-y-auto">
<div class="card shadow-sm consumer-status-card">
<div class="card-body">
<div class="d-flex flex-wrap align-items-center column-gap-2">
<p class="m-0 small text-muted">{{getStatusSummary()}}</p>
<div class="d-flex align-items-center gap-2 ms-auto">
@if (getStatusCompleted().length > 0) {
<a class="btn-link small text-nowrap" (click)="dismissCompleted()" [routerLink]="[]">
<span class="me-1" i18n="This button dismisses all status messages about processed documents in the tray (failed and successful)">Dismiss completed</span>
<i-bs name="check2-all"></i-bs>
</a>
}
<button type="button" class="btn btn-sm btn-link p-0 text-muted" (click)="toggleCollapsed()"
[attr.aria-expanded]="!collapsed()" aria-label="Toggle document processing status" i18n-aria-label>
<i-bs [name]="collapsed() ? 'chevron-up' : 'chevron-down'"></i-bs>
</button>
</div>
</div>
@if (!collapsed()) {
@for (status of getStatus(); track status) {
<ng-container [ngTemplateOutlet]="consumerAlert" [ngTemplateOutletContext]="{ $implicit: status }"></ng-container>
}
}
</div>
</div>
</div>
</div>
}
<ng-template #consumerAlert let-status>
<ngb-alert type="secondary" class="mt-2 mb-0" [dismissible]="isFinished(status)" (closed)="dismiss(status)">
<h6 class="alert-heading">{{status.filename}}</h6>
@if (!isFinished(status) || (isFinished(status) && !status.documentId)) {
<p class="mb-0 pb-1">{{status.message}}</p>
}
<ngb-progressbar [value]="status.getProgress()" [max]="1" [type]="getStatusColor(status)"></ngb-progressbar>
<div *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
@if (isFinished(status)) {
<div>
@if (status.documentId) {
<a class="btn btn-sm btn-outline-primary btn-open" [routerLink]="['/documents', status.documentId]" (click)="dismiss(status)">
<small i18n>Open document</small>
<i-bs name="arrow-right-short"></i-bs>
</a>
}
</div>
}
</div>
</ngb-alert>
</ng-template>
@@ -0,0 +1,36 @@
:host ::ng-deep i-bs svg {
margin-top: -3px;
}
.alert-heading {
font-size: 80%;
font-weight: bold;
}
.btn-open {
line-height: 1;
i-bs {
margin-top: -1px;
}
}
.consumer-status-card {
background-color: rgba(var(--bs-body-bg-rgb), .95) !important;
}
.consumer-status-list {
max-height: 60vh;
z-index: 1030;
}
.progress {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
height: auto;
mix-blend-mode: soft-light;
pointer-events: none;
}
@@ -0,0 +1,206 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { RouterTestingModule } from '@angular/router/testing'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { routes } from 'src/app/app-routing.module'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { PermissionsService } from 'src/app/services/permissions.service'
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
import {
FileStatus,
FileStatusPhase,
WebsocketStatusService,
} from 'src/app/services/websocket-status.service'
import { UploadStatusTrayComponent } from './upload-status-tray.component'
const FAILED_STATUSES = [new FileStatus()]
const WORKING_STATUSES = [new FileStatus(), new FileStatus()]
const STARTED_STATUSES = [new FileStatus(), new FileStatus(), new FileStatus()]
const SUCCESS_STATUSES = [
new FileStatus(),
new FileStatus(),
new FileStatus(),
new FileStatus(),
]
const DEFAULT_STATUSES = [
new FileStatus(),
new FileStatus(),
new FileStatus(),
new FileStatus(),
new FileStatus(),
new FileStatus(),
]
describe('UploadStatusTrayComponent', () => {
let component: UploadStatusTrayComponent
let fixture: ComponentFixture<UploadStatusTrayComponent>
let websocketStatusService: WebsocketStatusService
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [
RouterTestingModule.withRoutes(routes),
NgxBootstrapIconsModule.pick(allIcons),
UploadStatusTrayComponent,
],
providers: [
PermissionsGuard,
{
provide: PermissionsService,
useValue: {
currentUserCan: () => true,
},
},
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
}).compileComponents()
websocketStatusService = TestBed.inject(WebsocketStatusService)
fixture = TestBed.createComponent(UploadStatusTrayComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should not render without statuses', () => {
expect(
fixture.nativeElement.querySelector('.consumer-status-card')
).toBeNull()
})
it('should render statuses and support collapsing', () => {
const status = new FileStatus()
status.filename = 'invoice.pdf'
status.phase = FileStatusPhase.WORKING
jest
.spyOn(websocketStatusService, 'getConsumerStatus')
.mockReturnValue([status])
// a mocked return value doesn't mark the zoneless view dirty, so re-create it
fixture = TestBed.createComponent(UploadStatusTrayComponent)
component = fixture.componentInstance
fixture.detectChanges()
const el: HTMLElement = fixture.nativeElement
expect(el.querySelector('.consumer-status-card')).not.toBeNull()
expect(el.textContent).toContain('invoice.pdf')
component.toggleCollapsed()
fixture.detectChanges()
expect(el.querySelector('.consumer-status-card')).not.toBeNull()
expect(el.textContent).not.toContain('invoice.pdf')
component.toggleCollapsed()
fixture.detectChanges()
expect(el.textContent).toContain('invoice.pdf')
})
it('should expand again once all statuses are dismissed', () => {
const status = websocketStatusService.newFileUpload('invoice.pdf')
fixture.detectChanges()
component.toggleCollapsed()
expect(component.collapsed()).toBe(true)
websocketStatusService.dismiss(status)
fixture.detectChanges()
expect(component.collapsed()).toBe(false)
})
it('should expand on request', () => {
component.toggleCollapsed()
expect(component.collapsed()).toBe(true)
component.expand()
expect(component.collapsed()).toBe(false)
})
it('should expand when new uploads start', () => {
const uploadDocumentsService = TestBed.inject(UploadDocumentsService)
jest
.spyOn(uploadDocumentsService, 'uploadFile')
.mockImplementation(() => {})
websocketStatusService.newFileUpload('invoice.pdf')
fixture.detectChanges()
component.toggleCollapsed()
expect(component.collapsed()).toBe(true)
uploadDocumentsService.uploadFiles([new File(['one'], 'one.pdf')])
expect(component.collapsed()).toBe(false)
})
it('should generate stats summary', () => {
mockConsumerStatuses(websocketStatusService)
expect(component.getStatusSummary()).toEqual(
'Processing: 6, Failed: 1, Added: 4'
)
})
it('should report an upload progress summary', () => {
mockConsumerStatuses(websocketStatusService)
expect(component.getTotalUploadProgress()).toEqual(0.75)
})
it('should change color by status phase', () => {
const processingStatus = new FileStatus()
processingStatus.phase = FileStatusPhase.WORKING
expect(component.getStatusColor(processingStatus)).toEqual('primary')
processingStatus.phase = FileStatusPhase.UPLOADING
expect(component.getStatusColor(processingStatus)).toEqual('primary')
const failedStatus = new FileStatus()
failedStatus.phase = FileStatusPhase.FAILED
expect(component.getStatusColor(failedStatus)).toEqual('danger')
const successStatus = new FileStatus()
successStatus.phase = FileStatusPhase.SUCCESS
expect(component.getStatusColor(successStatus)).toEqual('success')
})
it('should allow dismissing an alert', () => {
const dismissSpy = jest.spyOn(websocketStatusService, 'dismiss')
component.dismiss(new FileStatus())
expect(dismissSpy).toHaveBeenCalled()
})
it('should allow dismissing completed alerts', () => {
jest.useFakeTimers()
mockConsumerStatuses(websocketStatusService)
fixture.detectChanges()
jest
.spyOn(component, 'getStatusCompleted')
.mockImplementation(() => SUCCESS_STATUSES)
const dismissSpy = jest.spyOn(websocketStatusService, 'dismiss')
component.dismissCompleted()
jest.advanceTimersByTime(1000)
fixture.detectChanges()
expect(dismissSpy).toHaveBeenCalledTimes(4)
jest.useRealTimers()
})
})
function mockConsumerStatuses(consumerStatusService) {
const partialUpload1 = new FileStatus()
partialUpload1.currentPhaseProgress = 50
partialUpload1.currentPhaseMaxProgress = 50
const partialUpload2 = new FileStatus()
partialUpload2.currentPhaseProgress = 25
partialUpload2.currentPhaseMaxProgress = 50
jest
.spyOn(consumerStatusService, 'getConsumerStatus')
.mockImplementation((phase) => {
switch (phase) {
case FileStatusPhase.FAILED:
return FAILED_STATUSES
case FileStatusPhase.WORKING:
return WORKING_STATUSES
case FileStatusPhase.STARTED:
return STARTED_STATUSES
case FileStatusPhase.SUCCESS:
return SUCCESS_STATUSES
case FileStatusPhase.UPLOADING:
return [partialUpload1, partialUpload2]
default:
return DEFAULT_STATUSES
}
})
jest
.spyOn(consumerStatusService, 'getConsumerStatusNotCompleted')
.mockImplementation(() => {
return DEFAULT_STATUSES
})
}
@@ -0,0 +1,165 @@
import { NgClass, NgTemplateOutlet } from '@angular/common'
import { Component, effect, inject, signal } from '@angular/core'
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import { RouterModule } from '@angular/router'
import {
NgbAlertModule,
NgbProgressbarModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import {
PermissionAction,
PermissionsService,
PermissionType,
} from 'src/app/services/permissions.service'
import { SettingsService } from 'src/app/services/settings.service'
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
import {
FileStatus,
FileStatusPhase,
WebsocketStatusService,
} from 'src/app/services/websocket-status.service'
/** Floating list of document consumption statuses, shown on every page */
@Component({
selector: 'pngx-upload-status-tray',
templateUrl: './upload-status-tray.component.html',
styleUrls: ['./upload-status-tray.component.scss'],
imports: [
IfPermissionsDirective,
NgClass,
NgTemplateOutlet,
RouterModule,
NgbAlertModule,
NgbProgressbarModule,
NgxBootstrapIconsModule,
],
})
export class UploadStatusTrayComponent extends ComponentWithPermissions {
private websocketStatusService = inject(WebsocketStatusService)
private settingsService = inject(SettingsService)
private permissionsService = inject(PermissionsService)
private uploadDocumentsService = inject(UploadDocumentsService)
readonly collapsed = signal(false)
constructor() {
super()
// start expanded again for the next uploads once everything is dismissed
effect(() => {
if (this.getStatus().length === 0) this.collapsed.set(false)
})
this.uploadDocumentsService
.onUploadsStarted()
.pipe(takeUntilDestroyed())
.subscribe(() => this.expand())
}
get canAddDocuments(): boolean {
return this.permissionsService.currentUserCan(
PermissionAction.Add,
PermissionType.Document
)
}
getStatus() {
return this.websocketStatusService.getConsumerStatus()
}
getStatusSummary() {
let strings = []
let countUploadingAndProcessing =
this.websocketStatusService.getConsumerStatusNotCompleted().length
let countFailed = this.getStatusFailed().length
let countSuccess = this.getStatusSuccess().length
if (countUploadingAndProcessing > 0) {
strings.push($localize`Processing: ${countUploadingAndProcessing}`)
}
if (countFailed > 0) {
strings.push($localize`Failed: ${countFailed}`)
}
if (countSuccess > 0) {
strings.push($localize`Added: ${countSuccess}`)
}
return strings.join(
$localize`:this string is used to separate processing, failed and added on the file upload widget:, `
)
}
getStatusUploading() {
return this.websocketStatusService.getConsumerStatus(
FileStatusPhase.UPLOADING
)
}
getStatusFailed() {
return this.websocketStatusService.getConsumerStatus(FileStatusPhase.FAILED)
}
getStatusSuccess() {
return this.websocketStatusService.getConsumerStatus(
FileStatusPhase.SUCCESS
)
}
getStatusCompleted() {
return this.websocketStatusService.getConsumerStatusCompleted()
}
getTotalUploadProgress() {
let current = 0
let max = 0
this.getStatusUploading().forEach((status) => {
current += status.currentPhaseProgress
max += status.currentPhaseMaxProgress
})
return current / Math.max(max, 1)
}
isFinished(status: FileStatus) {
return (
status.phase == FileStatusPhase.FAILED ||
status.phase == FileStatusPhase.SUCCESS
)
}
getStatusColor(status: FileStatus) {
switch (status.phase) {
case FileStatusPhase.UPLOADING:
case FileStatusPhase.STARTED:
case FileStatusPhase.WORKING:
return 'primary'
case FileStatusPhase.FAILED:
return 'danger'
case FileStatusPhase.SUCCESS:
return 'success'
}
}
toggleCollapsed() {
this.collapsed.update((collapsed) => !collapsed)
}
expand() {
this.collapsed.set(false)
}
dismiss(status: FileStatus) {
this.websocketStatusService.dismiss(status)
}
dismissCompleted() {
this.getStatusCompleted().forEach((status) =>
this.websocketStatusService.dismiss(status)
)
}
get slimSidebarEnabled(): boolean {
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
}
}
@@ -173,7 +173,7 @@ describe('DashboardComponent', () => {
it('should update saved view sorting on drag + drop, show info', () => { it('should update saved view sorting on drag + drop, show info', () => {
const settingsSpy = jest.spyOn(settingsService, 'updateDashboardViewsSort') const settingsSpy = jest.spyOn(settingsService, 'updateDashboardViewsSort')
const toastSpy = jest.spyOn(toastService, 'showInfo') const toastSpy = jest.spyOn(toastService, 'showSuccess')
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true)) jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
component.onDrop({ previousIndex: 0, currentIndex: 1 } as CdkDragDrop< component.onDrop({ previousIndex: 0, currentIndex: 1 } as CdkDragDrop<
SavedView[] SavedView[]
@@ -87,7 +87,7 @@ export class DashboardComponent extends ComponentWithPermissions {
.updateDashboardViewsSort(this.dashboardViews()) .updateDashboardViewsSort(this.dashboardViews())
.subscribe({ .subscribe({
next: () => { next: () => {
this.toastService.showInfo($localize`Dashboard updated`) this.toastService.showSuccess($localize`Dashboard updated`)
}, },
error: (e) => { error: (e) => {
this.toastService.showError($localize`Error updating dashboard`, e) this.toastService.showError($localize`Error updating dashboard`, e)
@@ -8,59 +8,5 @@
</button> </button>
<input type="file" class="visually-hidden" (change)="onFileSelected($event)" multiple #fileUpload> <input type="file" class="visually-hidden" (change)="onFileSelected($event)" multiple #fileUpload>
</form> </form>
@if (getStatus().length > 0) {
<div class="fixed-bottom p-2 p-md-4 d-flex justify-content-end pe-none consumer-status-list" [ngClass]="slimSidebarEnabled ? 'col-slim' : 'offset-sidebar-expanded'">
<div class="col col-lg-4 col-xl-3 ps-0 pe-0 ps-lg-3 pe-lg-0 pe-auto overflow-y-scroll">
<div class="card shadow-sm consumer-status-card">
<div class="card-body">
<div class="row">
<div class="col d-flex justify-content-between align-items-center">
@if (getStatus().length > 0) {
<p class="m-0 small text-muted">{{getStatusSummary()}}</p>
}
@if (getStatusCompleted().length > 0) {
<a class="btn-link" (click)="dismissCompleted()" [routerLink]="[]" >
<span class="me-1" i18n="This button dismisses all status messages about processed documents on the dashboard (failed and successful)">Dismiss completed</span>
<i-bs name="check2-all"></i-bs>
</a>
}
</div>
</div>
<div class="row">
<div class="col">
@for (status of getStatus(); track status) {
<div>
<ng-container [ngTemplateOutlet]="consumerAlert" [ngTemplateOutletContext]="{ $implicit: status }"></ng-container>
</div>
}
</div>
</div>
</div>
</div>
</div>
</div>
}
</div> </div>
</pngx-widget-frame> </pngx-widget-frame>
<ng-template #consumerAlert let-status>
<ngb-alert type="secondary" class="mt-2 mb-0" [dismissible]="isFinished(status)" (closed)="dismiss(status)">
<h6 class="alert-heading">{{status.filename}}</h6>
@if (!isFinished(status) || (isFinished(status) && !status.documentId)) {
<p class="mb-0 pb-1">{{status.message}}</p>
}
<ngb-progressbar [value]="status.getProgress()" [max]="1" [type]="getStatusColor(status)"></ngb-progressbar>
<div *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
@if (isFinished(status)) {
<div>
@if (status.documentId) {
<a class="btn btn-sm btn-outline-primary btn-open" [routerLink]="['/documents', status.documentId]" (click)="dismiss(status)">
<small i18n>Open document</small>
<i-bs name="arrow-right-short"></i-bs>
</a>
}
</div>
}
</div>
</ngb-alert>
</ng-template>
@@ -20,48 +20,6 @@
font-size: 0.75rem; font-size: 0.75rem;
} }
.alert-heading {
font-size: 80%;
font-weight: bold;
}
.alerts-hidden {
.btn {
line-height: 1;
}
}
.btn-open {
line-height: 1;
i-bs {
margin-top: -1px;
}
}
.progress {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
height: auto;
mix-blend-mode: soft-light;
pointer-events: none;
}
::ng-deep .ngx-file-drop__drop-zone--over { ::ng-deep .ngx-file-drop__drop-zone--over {
background-color: var(--pngx-primary-faded) !important; background-color: var(--pngx-primary-faded) !important;
} }
.consumer-status-card {
background-color: rgba(var(--bs-body-bg-rgb), .95) !important;
}
.consumer-status-list {
max-height: calc(100vh - 312px); // e.g. below the upload button, mobile
@media screen and (min-width: 768px) {
max-height: calc(100vh - 208px); // e.g. below the upload button
}
}
@@ -9,35 +9,11 @@ import { routes } from 'src/app/app-routing.module'
import { PermissionsGuard } from 'src/app/guards/permissions.guard' import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { PermissionsService } from 'src/app/services/permissions.service' import { PermissionsService } from 'src/app/services/permissions.service'
import { UploadDocumentsService } from 'src/app/services/upload-documents.service' import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
import {
FileStatus,
FileStatusPhase,
WebsocketStatusService,
} from 'src/app/services/websocket-status.service'
import { UploadFileWidgetComponent } from './upload-file-widget.component' import { UploadFileWidgetComponent } from './upload-file-widget.component'
const FAILED_STATUSES = [new FileStatus()]
const WORKING_STATUSES = [new FileStatus(), new FileStatus()]
const STARTED_STATUSES = [new FileStatus(), new FileStatus(), new FileStatus()]
const SUCCESS_STATUSES = [
new FileStatus(),
new FileStatus(),
new FileStatus(),
new FileStatus(),
]
const DEFAULT_STATUSES = [
new FileStatus(),
new FileStatus(),
new FileStatus(),
new FileStatus(),
new FileStatus(),
new FileStatus(),
]
describe('UploadFileWidgetComponent', () => { describe('UploadFileWidgetComponent', () => {
let component: UploadFileWidgetComponent let component: UploadFileWidgetComponent
let fixture: ComponentFixture<UploadFileWidgetComponent> let fixture: ComponentFixture<UploadFileWidgetComponent>
let websocketStatusService: WebsocketStatusService
let uploadDocumentsService: UploadDocumentsService let uploadDocumentsService: UploadDocumentsService
beforeEach(async () => { beforeEach(async () => {
@@ -61,7 +37,6 @@ describe('UploadFileWidgetComponent', () => {
], ],
}).compileComponents() }).compileComponents()
websocketStatusService = TestBed.inject(WebsocketStatusService)
uploadDocumentsService = TestBed.inject(UploadDocumentsService) uploadDocumentsService = TestBed.inject(UploadDocumentsService)
fixture = TestBed.createComponent(UploadFileWidgetComponent) fixture = TestBed.createComponent(UploadFileWidgetComponent)
component = fixture.componentInstance component = fixture.componentInstance
@@ -85,93 +60,10 @@ describe('UploadFileWidgetComponent', () => {
'file.pdf' 'file.pdf'
) )
const fileInput = fixture.debugElement.query(By.css('input')) const fileInput = fixture.debugElement.query(By.css('input'))
jest.spyOn(fileInput.nativeElement, 'files', 'get').mockReturnValue({
item: () => file,
length: 1,
[Symbol.iterator]: () => ({
next: () => ({ done: false, value: file }),
}),
} as any)
fileInput.nativeElement.dispatchEvent(new Event('change'))
expect(uploadSpy).toHaveBeenCalled()
})
it('should generate stats summary', () => {
mockConsumerStatuses(websocketStatusService)
expect(component.getStatusSummary()).toEqual(
'Processing: 6, Failed: 1, Added: 4'
)
})
it('should report an upload progress summary', () => {
mockConsumerStatuses(websocketStatusService)
expect(component.getTotalUploadProgress()).toEqual(0.75)
})
it('should change color by status phase', () => {
const processingStatus = new FileStatus()
processingStatus.phase = FileStatusPhase.WORKING
expect(component.getStatusColor(processingStatus)).toEqual('primary')
processingStatus.phase = FileStatusPhase.UPLOADING
expect(component.getStatusColor(processingStatus)).toEqual('primary')
const failedStatus = new FileStatus()
failedStatus.phase = FileStatusPhase.FAILED
expect(component.getStatusColor(failedStatus)).toEqual('danger')
const successStatus = new FileStatus()
successStatus.phase = FileStatusPhase.SUCCESS
expect(component.getStatusColor(successStatus)).toEqual('success')
})
it('should allow dismissing an alert', () => {
const dismissSpy = jest.spyOn(websocketStatusService, 'dismiss')
component.dismiss(new FileStatus())
expect(dismissSpy).toHaveBeenCalled()
})
it('should allow dismissing completed alerts', () => {
jest.useFakeTimers()
mockConsumerStatuses(websocketStatusService)
fixture.detectChanges()
jest jest
.spyOn(component, 'getStatusCompleted') .spyOn(fileInput.nativeElement, 'files', 'get')
.mockImplementation(() => SUCCESS_STATUSES) .mockReturnValue([file] as any)
const dismissSpy = jest.spyOn(websocketStatusService, 'dismiss') fileInput.nativeElement.dispatchEvent(new Event('change'))
component.dismissCompleted() expect(uploadSpy).toHaveBeenCalledWith(file)
jest.advanceTimersByTime(1000)
fixture.detectChanges()
expect(dismissSpy).toHaveBeenCalledTimes(4)
jest.useRealTimers()
}) })
}) })
function mockConsumerStatuses(consumerStatusService) {
const partialUpload1 = new FileStatus()
partialUpload1.currentPhaseProgress = 50
partialUpload1.currentPhaseMaxProgress = 50
const partialUpload2 = new FileStatus()
partialUpload2.currentPhaseProgress = 25
partialUpload2.currentPhaseMaxProgress = 50
jest
.spyOn(consumerStatusService, 'getConsumerStatus')
.mockImplementation((phase) => {
switch (phase) {
case FileStatusPhase.FAILED:
return FAILED_STATUSES
case FileStatusPhase.WORKING:
return WORKING_STATUSES
case FileStatusPhase.STARTED:
return STARTED_STATUSES
case FileStatusPhase.SUCCESS:
return SUCCESS_STATUSES
case FileStatusPhase.UPLOADING:
return [partialUpload1, partialUpload2]
default:
return DEFAULT_STATUSES
}
})
jest
.spyOn(consumerStatusService, 'getConsumerStatusNotCompleted')
.mockImplementation(() => {
return DEFAULT_STATUSES
})
}
@@ -1,23 +1,9 @@
import { NgClass, NgTemplateOutlet } from '@angular/common' import { Component, inject } from '@angular/core'
import { Component, QueryList, ViewChildren, inject } from '@angular/core'
import { RouterModule } from '@angular/router'
import {
NgbAlert,
NgbAlertModule,
NgbProgressbarModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap' import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component' import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { SettingsService } from 'src/app/services/settings.service'
import { UploadDocumentsService } from 'src/app/services/upload-documents.service' import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
import {
FileStatus,
FileStatusPhase,
WebsocketStatusService,
} from 'src/app/services/websocket-status.service'
import { WidgetFrameComponent } from '../widget-frame/widget-frame.component' import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
@Component({ @Component({
@@ -27,117 +13,16 @@ import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
imports: [ imports: [
WidgetFrameComponent, WidgetFrameComponent,
IfPermissionsDirective, IfPermissionsDirective,
NgClass,
NgTemplateOutlet,
RouterModule,
NgbAlertModule,
NgbProgressbarModule,
NgxBootstrapIconsModule, NgxBootstrapIconsModule,
TourNgBootstrap, TourNgBootstrap,
], ],
}) })
export class UploadFileWidgetComponent extends ComponentWithPermissions { export class UploadFileWidgetComponent extends ComponentWithPermissions {
private websocketStatusService = inject(WebsocketStatusService)
private uploadDocumentsService = inject(UploadDocumentsService) private uploadDocumentsService = inject(UploadDocumentsService)
settingsService = inject(SettingsService)
@ViewChildren(NgbAlert) alerts: QueryList<NgbAlert>
getStatus() {
return this.websocketStatusService.getConsumerStatus()
}
getStatusSummary() {
let strings = []
let countUploadingAndProcessing =
this.websocketStatusService.getConsumerStatusNotCompleted().length
let countFailed = this.getStatusFailed().length
let countSuccess = this.getStatusSuccess().length
if (countUploadingAndProcessing > 0) {
strings.push($localize`Processing: ${countUploadingAndProcessing}`)
}
if (countFailed > 0) {
strings.push($localize`Failed: ${countFailed}`)
}
if (countSuccess > 0) {
strings.push($localize`Added: ${countSuccess}`)
}
return strings.join(
$localize`:this string is used to separate processing, failed and added on the file upload widget:, `
)
}
getStatusUploading() {
return this.websocketStatusService.getConsumerStatus(
FileStatusPhase.UPLOADING
)
}
getStatusFailed() {
return this.websocketStatusService.getConsumerStatus(FileStatusPhase.FAILED)
}
getStatusSuccess() {
return this.websocketStatusService.getConsumerStatus(
FileStatusPhase.SUCCESS
)
}
getStatusCompleted() {
return this.websocketStatusService.getConsumerStatusCompleted()
}
getTotalUploadProgress() {
let current = 0
let max = 0
this.getStatusUploading().forEach((status) => {
current += status.currentPhaseProgress
max += status.currentPhaseMaxProgress
})
return current / Math.max(max, 1)
}
isFinished(status: FileStatus) {
return (
status.phase == FileStatusPhase.FAILED ||
status.phase == FileStatusPhase.SUCCESS
)
}
getStatusColor(status: FileStatus) {
switch (status.phase) {
case FileStatusPhase.UPLOADING:
case FileStatusPhase.STARTED:
case FileStatusPhase.WORKING:
return 'primary'
case FileStatusPhase.FAILED:
return 'danger'
case FileStatusPhase.SUCCESS:
return 'success'
}
}
dismiss(status: FileStatus) {
this.websocketStatusService.dismiss(status)
}
dismissCompleted() {
this.getStatusCompleted().forEach((status) =>
this.websocketStatusService.dismiss(status)
)
}
public onFileSelected(event: Event) { public onFileSelected(event: Event) {
const files = (event.target as HTMLInputElement).files const input = event.target as HTMLInputElement
for (let i = 0; i < files?.length; i++) { this.uploadDocumentsService.uploadFiles(Array.from(input.files ?? []))
const file = files.item(i) input.value = ''
file && this.uploadDocumentsService.uploadFile(file)
}
}
get slimSidebarEnabled(): boolean {
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
} }
} }
@@ -1,25 +1,4 @@
<pngx-page-header [title]="title()" [id]="documentId()"> <pngx-page-header [title]="title()" [id]="documentId()">
@if (archiveContentRenderType === ContentRenderType.PDF && !useNativePdfViewer) {
@if (previewNumPages()) {
<div class="input-group input-group-sm ms-2 d-none d-md-flex">
<div class="input-group-text" i18n>Page</div>
<input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewNumPages()" [ngModel]="previewCurrentPage()" (ngModelChange)="previewCurrentPage.set($event)" />
<div class="input-group-text" i18n>of {{previewNumPages()}}</div>
</div>
}
<div class="input-group input-group-sm me-md-5 d-none d-md-flex">
<button class="btn btn-outline-secondary" (click)="decreaseZoom()" i18n>-</button>
<select class="form-select" (change)="setZoom($event.target.value)" [ngModel]="currentZoom">
@for (setting of zoomSettings; track setting) {
<option [value]="setting">
{{ getZoomSettingTitle(setting) }}
</option>
}
</select>
<button class="btn btn-outline-secondary" (click)="increaseZoom()" i18n>+</button>
</div>
}
<button type="button" class="btn btn-sm btn-outline-danger me-md-4" (click)="delete()" [disabled]="!userIsOwner" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }" aria-label="Delete" i18n-aria-label> <button type="button" class="btn btn-sm btn-outline-danger me-md-4" (click)="delete()" [disabled]="!userIsOwner" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }" aria-label="Delete" i18n-aria-label>
<i-bs width="1.2em" height="1.2em" name="trash"></i-bs><span class="d-none d-lg-inline ps-1" i18n>Delete</span> <i-bs width="1.2em" height="1.2em" name="trash"></i-bs><span class="d-none d-lg-inline ps-1" i18n>Delete</span>
</button> </button>
@@ -34,7 +13,7 @@
(versionsUpdated)="onVersionsUpdated($event)" (versionsUpdated)="onVersionsUpdated($event)"
/> />
<div class="btn-group"> <div class="btn-group ms-4">
<button (click)="download()" class="btn btn-sm btn-outline-primary" [disabled]="downloading()" aria-label="Download" i18n-aria-label> <button (click)="download()" class="btn btn-sm btn-outline-primary" [disabled]="downloading()" aria-label="Download" i18n-aria-label>
@if (downloading()) { @if (downloading()) {
<div class="spinner-border spinner-border-sm" role="status"></div> <div class="spinner-border spinner-border-sm" role="status"></div>
@@ -67,24 +46,40 @@
</button> </button>
<div ngbDropdownMenu aria-labelledby="actionsDropdown" class="shadow"> <div ngbDropdownMenu aria-labelledby="actionsDropdown" class="shadow">
<button ngbDropdownItem (click)="reprocess()" [disabled]="!userCanEdit || !userIsOwner"> <button ngbDropdownItem (click)="reprocess()" [disabled]="!userCanEdit || !userIsOwner">
<i-bs width="1em" height="1em" name="arrow-counterclockwise" class="me-1"></i-bs><span i18n>Reprocess</span> <i-bs width="1em" height="1em" name="arrow-counterclockwise" class="me-2"></i-bs><span i18n>Reprocess</span>
</button> </button>
<button ngbDropdownItem (click)="printDocument()" [hidden]="useNativePdfViewer || isMobile"> <button ngbDropdownItem (click)="printDocument()" [hidden]="useNativePdfViewer || isMobile">
<i-bs width="1em" height="1em" name="printer" class="me-1"></i-bs><span i18n>Print</span> <i-bs width="1em" height="1em" name="printer" class="me-2"></i-bs><span i18n>Print</span>
</button> </button>
<button ngbDropdownItem (click)="moreLike()"> <button ngbDropdownItem (click)="moreLike()">
<i-bs width="1em" height="1em" name="diagram-3" class="me-1"></i-bs><span i18n>More like this</span> <i-bs width="1em" height="1em" name="diagram-3" class="me-2"></i-bs><span i18n>More like this</span>
</button> </button>
<button ngbDropdownItem (click)="editPdf()" [disabled]="!userIsOwner || !userCanEdit || originalContentRenderType !== ContentRenderType.PDF"> <button ngbDropdownItem (click)="editPdf()" [disabled]="!userIsOwner || !userCanEdit || originalContentRenderType !== ContentRenderType.PDF">
<i-bs name="pencil" class="me-1"></i-bs><ng-container i18n>PDF Editor</ng-container> <i-bs name="pencil" class="me-2"></i-bs><ng-container i18n>PDF Editor</ng-container>
</button> </button>
<div class="d-flex" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.OcrTemplate }">
<button ngbDropdownItem (click)="runZoneOcr()" [disabled]="!userCanEdit || networkActive()">
<i-bs width="1em" height="1em" name="bounding-box-circles" class="me-2"></i-bs><span i18n>Run Zone OCR</span>
</button>
<button
ngbDropdownItem
class="w-auto flex-shrink-0 px-2"
(click)="runZoneOcrWithOptions()"
[disabled]="!userCanEdit || networkActive()"
title="Choose OCR template"
i18n-title
>
<i-bs width="1em" height="1em" name="sliders2"></i-bs>
</button>
</div>
@if (userIsOwner && (requiresPassword || password)) { @if (userIsOwner && (requiresPassword || password)) {
<button ngbDropdownItem (click)="removePassword()" [disabled]="!password"> <button ngbDropdownItem (click)="removePassword()" [disabled]="!password">
<i-bs name="unlock" class="me-1"></i-bs><ng-container i18n>Remove Password</ng-container> <i-bs name="unlock" class="me-2"></i-bs><ng-container i18n>Remove Password</ng-container>
</button> </button>
} }
</div> </div>
@@ -94,13 +89,13 @@
<button class="btn btn-sm btn-outline-primary" id="sendDropdown" ngbDropdownToggle aria-label="Send" i18n-aria-label> <button class="btn btn-sm btn-outline-primary" id="sendDropdown" ngbDropdownToggle aria-label="Send" i18n-aria-label>
<i-bs name="send"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Send</ng-container></div> <i-bs name="send"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Send</ng-container></div>
</button> </button>
<div ngbDropdownMenu aria-labelledby="actionsDropdown" class="shadow"> <div ngbDropdownMenu aria-labelledby="sendDropdown" class="shadow">
<button ngbDropdownItem (click)="openShareLinks()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.ShareLink }"> <button ngbDropdownItem (click)="openShareLinks()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.ShareLink }">
<i-bs name="link" class="me-1"></i-bs><span i18n>Share Links</span> <i-bs name="link" class="me-2"></i-bs><span i18n>Share Links</span>
</button> </button>
@if (emailEnabled) { @if (emailEnabled) {
<button ngbDropdownItem (click)="openEmailDocument()"> <button ngbDropdownItem (click)="openEmailDocument()">
<i-bs name="envelope" class="me-1"></i-bs><span i18n>Email</span> <i-bs name="envelope" class="me-2"></i-bs><span i18n>Email</span>
</button> </button>
} }
</div> </div>
@@ -111,10 +106,10 @@
<div class="row"> <div class="row">
<div class="col-md-6 col-xl-5 mb-4"> <div class="col-md-6 col-xl-5 mb-4">
<form [formGroup]='documentForm' (ngSubmit)="save()"> <form [formGroup]='documentForm' (ngSubmit)="save()" [attr.inert]="zoneOcrRunning() ? '' : null" [attr.aria-busy]="zoneOcrRunning()">
<div class="btn-toolbar justify-content-end mb-1 pb-3 gap-2 row-gap-2 border-bottom"> <div class="btn-toolbar mb-1 pb-3 gap-2 row-gap-2 border-bottom">
<div class="btn-group me-auto"> <div class="btn-group">
<button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Close" (click)="close()"> <button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Close" (click)="close()">
<i-bs width="1.2em" height="1.2em" name="x"></i-bs> <i-bs width="1.2em" height="1.2em" name="x"></i-bs>
</button> </button>
@@ -127,43 +122,30 @@
</div> </div>
<ng-container *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }"> <ng-container *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
<div class="d-flex gap-2"> <div class="btn-group ms-3">
<div class="btn-group"> <pngx-suggestions-dropdown *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }"
<pngx-suggestions-dropdown *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }" [disabled]="!userCanEdit || suggestionsLoading()"
[disabled]="!userCanEdit || suggestionsLoading()" [loading]="suggestionsLoading()"
[loading]="suggestionsLoading()" [suggestions]="suggestions()"
[suggestions]="suggestions()" [aiEnabled]="aiEnabled"
[aiEnabled]="aiEnabled" [source]="suggestionSource"
[source]="suggestionSource" [defaultSource]="defaultSuggestionSource"
[defaultSource]="defaultSuggestionSource" [fetchedSources]="fetchedSuggestionSources()"
[fetchedSources]="fetchedSuggestionSources()" [appliedTags]="documentForm.value.tags"
[appliedTags]="documentForm.value.tags" [appliedCorrespondent]="documentForm.value.correspondent"
[appliedCorrespondent]="documentForm.value.correspondent" [appliedDocumentType]="documentForm.value.document_type"
[appliedDocumentType]="documentForm.value.document_type" [appliedStoragePath]="documentForm.value.storage_path"
[appliedStoragePath]="documentForm.value.storage_path" [appliedTitle]="documentForm.value.title"
[appliedTitle]="documentForm.value.title" [appliedCreated]="documentForm.value.created"
[appliedCreated]="documentForm.value.created" (getSuggestions)="getSuggestions($event)"
(getSuggestions)="getSuggestions($event)" (sourceChange)="suggestionSourceOverride.set($event)"
(sourceChange)="suggestionSourceOverride.set($event)" (addTag)="createTag($event)"
(addTag)="createTag($event)" (addDocumentType)="createDocumentType($event)"
(addDocumentType)="createDocumentType($event)" (addCorrespondent)="createCorrespondent($event)">
(addCorrespondent)="createCorrespondent($event)"> </pngx-suggestions-dropdown>
</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>
<div class="ps-3"> <div class="ms-auto">
<ng-container *ngTemplateOutlet="saveButtons"></ng-container> <ng-container *ngTemplateOutlet="saveButtons"></ng-container>
</div> </div>
</ng-container> </ng-container>
@@ -290,8 +272,20 @@
} }
</div> </div>
<div class="d-flex justify-content-end border-top pt-3"> <div class="d-flex border-top pt-3">
<ng-container *ngTemplateOutlet="saveButtons"></ng-container> <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> </div>
</ng-template> </ng-template>
</li> </li>
@@ -328,9 +322,9 @@
<td i18n>Original filename</td> <td i18n>Original filename</td>
<td>{{metadata()?.original_filename}}</td> <td>{{metadata()?.original_filename}}</td>
</tr> </tr>
<tr> <tr class="align-middle">
<td i18n>Original SHA256 checksum</td> <td i18n>Original SHA256 checksum</td>
<td>{{metadata()?.original_checksum}}</td> <td><span class="badge bg-light font-monospace text-wrap text-break text-start">{{metadata()?.original_checksum}}</span></td>
</tr> </tr>
<tr> <tr>
<td i18n>Original file size</td> <td i18n>Original file size</td>
@@ -341,9 +335,9 @@
<td>{{metadata()?.original_mime_type}}</td> <td>{{metadata()?.original_mime_type}}</td>
</tr> </tr>
@if (metadata()?.has_archive_version) { @if (metadata()?.has_archive_version) {
<tr> <tr class="align-middle">
<td i18n>Archive SHA256 checksum</td> <td i18n>Archive SHA256 checksum</td>
<td>{{metadata()?.archive_checksum}}</td> <td><span class="badge bg-light font-monospace text-wrap text-break text-start">{{metadata()?.archive_checksum}}</span></td>
</tr> </tr>
} }
@if (metadata()?.has_archive_version) { @if (metadata()?.has_archive_version) {
@@ -408,7 +402,7 @@
<a ngbNavLink i18n>Permissions</a> <a ngbNavLink i18n>Permissions</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
<div class="mb-3"> <div class="mb-3">
<pngx-permissions-form [users]="users()" formControlName="permissions_form"></pngx-permissions-form> <pngx-permissions-form title="" [users]="users()" formControlName="permissions_form"></pngx-permissions-form>
</div> </div>
</ng-template> </ng-template>
</li> </li>
@@ -499,6 +493,29 @@
(loadError)="onError($event)" (loadError)="onError($event)"
(afterLoadComplete)="pdfPreviewLoaded($event)"> (afterLoadComplete)="pdfPreviewLoaded($event)">
</pngx-pdf-viewer> </pngx-pdf-viewer>
<div class="position-absolute bottom-0 start-50 translate-middle-x z-1 mb-3 d-flex gap-2 p-1 bg-body border rounded shadow">
@if (previewNumPages()) {
<div class="input-group input-group-sm w-auto flex-nowrap">
<input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewNumPages()" [ngModelOptions]="{standalone: true}" [ngModel]="previewCurrentPage()" (ngModelChange)="previewCurrentPage.set($event)" aria-label="Page" i18n-aria-label />
<div class="input-group-text" i18n>of {{previewNumPages()}}</div>
</div>
}
<div class="input-group input-group-sm w-auto flex-nowrap">
<button class="btn btn-outline-secondary" (click)="decreaseZoom()" aria-label="Zoom out" i18n-aria-label>
<i-bs width="1em" height="1em" name="dash"></i-bs>
</button>
<select class="form-select" (change)="setZoom($event.target.value)" [ngModelOptions]="{standalone: true}" [ngModel]="currentZoom" aria-label="Zoom" i18n-aria-label>
@for (setting of zoomSettings; track setting) {
<option [value]="setting">
{{ getZoomSettingTitle(setting) }}
</option>
}
</select>
<button class="btn btn-outline-secondary" (click)="increaseZoom()" aria-label="Zoom in" i18n-aria-label>
<i-bs width="1em" height="1em" name="plus"></i-bs>
</button>
</div>
</div>
</div> </div>
} @else { } @else {
<object [data]="previewUrl() | safeUrl" class="preview-sticky" width="100%"></object> <object [data]="previewUrl() | safeUrl" class="preview-sticky" width="100%"></object>
@@ -60,6 +60,7 @@ import { TagService } from 'src/app/services/rest/tag.service'
import { UserService } from 'src/app/services/rest/user.service' import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { TrashService } from 'src/app/services/trash.service'
import { WebsocketStatusService } from 'src/app/services/websocket-status.service' import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component' import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
@@ -137,6 +138,7 @@ describe('DocumentDetailComponent', () => {
let openDocumentsService: OpenDocumentsService let openDocumentsService: OpenDocumentsService
let modalService: NgbModal let modalService: NgbModal
let toastService: ToastService let toastService: ToastService
let trashService: TrashService
let documentListViewService: DocumentListViewService let documentListViewService: DocumentListViewService
let settingsService: SettingsService let settingsService: SettingsService
let customFieldsService: CustomFieldsService let customFieldsService: CustomFieldsService
@@ -285,6 +287,7 @@ describe('DocumentDetailComponent', () => {
documentService = TestBed.inject(DocumentService) documentService = TestBed.inject(DocumentService)
modalService = TestBed.inject(NgbModal) modalService = TestBed.inject(NgbModal)
toastService = TestBed.inject(ToastService) toastService = TestBed.inject(ToastService)
trashService = TestBed.inject(TrashService)
documentListViewService = TestBed.inject(DocumentListViewService) documentListViewService = TestBed.inject(DocumentListViewService)
settingsService = TestBed.inject(SettingsService) settingsService = TestBed.inject(SettingsService)
settingsService.currentUser.set({ id: 1 }) settingsService.currentUser.set({ id: 1 })
@@ -775,7 +778,7 @@ describe('DocumentDetailComponent', () => {
component.title.set('Foo Bar') component.title.set('Foo Bar')
const closeSpy = jest.spyOn(component, 'close') const closeSpy = jest.spyOn(component, 'close')
const patchSpy = jest.spyOn(documentService, 'patch') const patchSpy = jest.spyOn(documentService, 'patch')
const toastSpy = jest.spyOn(toastService, 'showInfo') const toastSpy = jest.spyOn(toastService, 'showSuccess')
patchSpy.mockImplementation((o) => of(doc)) patchSpy.mockImplementation((o) => of(doc))
component.save(true) component.save(true)
expect(patchSpy).toHaveBeenCalled() expect(patchSpy).toHaveBeenCalled()
@@ -790,7 +793,7 @@ describe('DocumentDetailComponent', () => {
component.title.set('Foo Bar') component.title.set('Foo Bar')
const closeSpy = jest.spyOn(component, 'close') const closeSpy = jest.spyOn(component, 'close')
const patchSpy = jest.spyOn(documentService, 'patch') const patchSpy = jest.spyOn(documentService, 'patch')
const toastSpy = jest.spyOn(toastService, 'showInfo') const toastSpy = jest.spyOn(toastService, 'showSuccess')
patchSpy.mockImplementation((o) => of(doc)) patchSpy.mockImplementation((o) => of(doc))
component.save() component.save()
expect(patchSpy).toHaveBeenCalled() expect(patchSpy).toHaveBeenCalled()
@@ -836,7 +839,7 @@ describe('DocumentDetailComponent', () => {
component.title.set('Foo Bar') component.title.set('Foo Bar')
const closeSpy = jest.spyOn(component, 'close') const closeSpy = jest.spyOn(component, 'close')
const patchSpy = jest.spyOn(documentService, 'patch') const patchSpy = jest.spyOn(documentService, 'patch')
const toastSpy = jest.spyOn(toastService, 'showInfo') const toastSpy = jest.spyOn(toastService, 'showSuccess')
patchSpy.mockImplementation(() => patchSpy.mockImplementation(() =>
throwError(() => new Error('failed to save')) throwError(() => new Error('failed to save'))
) )
@@ -957,12 +960,16 @@ describe('DocumentDetailComponent', () => {
const modalSpy = jest.spyOn(modalService, 'open') const modalSpy = jest.spyOn(modalService, 'open')
const deleteSpy = jest.spyOn(documentService, 'delete') const deleteSpy = jest.spyOn(documentService, 'delete')
deleteSpy.mockReturnValue(of(true)) deleteSpy.mockReturnValue(of(true))
const notifySpy = jest
.spyOn(trashService, 'notifyMovedToTrash')
.mockImplementation(() => {})
component.delete() component.delete()
expect(modalSpy).toHaveBeenCalled() expect(modalSpy).toHaveBeenCalled()
const modalCloseSpy = jest.spyOn(openModal, 'close') const modalCloseSpy = jest.spyOn(openModal, 'close')
openModal.componentInstance.confirmClicked.next() openModal.componentInstance.confirmClicked.next()
expect(deleteSpy).toHaveBeenCalled() expect(deleteSpy).toHaveBeenCalled()
expect(modalCloseSpy).toHaveBeenCalled() expect(modalCloseSpy).toHaveBeenCalled()
expect(notifySpy).toHaveBeenCalledWith(expect.any(String), [doc.id])
}) })
it('should allow retry delete if error', () => { it('should allow retry delete if error', () => {
@@ -1893,6 +1900,196 @@ describe('DocumentDetailComponent', () => {
expect(toastSpy).toHaveBeenCalledWith('Document reloaded.') expect(toastSpy).toHaveBeenCalledWith('Document reloaded.')
}) })
it('should run zone OCR, report results and reload the document', () => {
component.documentId.set(doc.id)
component.document.set(doc)
jest.spyOn(documentService, 'runZoneOcr').mockReturnValue(
of({
results: [
{ zone: 'Total', value: '102.10' },
{ zone: 'Due', value: '' },
],
} as any)
)
const loadSpy = jest
.spyOn(component as any, 'loadDocument')
.mockImplementation(() => {})
const notifySpy = jest.spyOn(component.docChangeNotifier, 'next')
const toastSpy = jest.spyOn(toastService, 'showInfo')
const modalSpy = jest.spyOn(modalService, 'open')
component.runZoneOcrWithOptions()
const dialog = modalSpy.mock.results[0].value.componentInstance
expect(dialog.message).toBe(
'Fields filled by the template will be overwritten.'
)
dialog.template = 3
dialog.confirmClicked.emit()
expect(documentService.runZoneOcr).toHaveBeenCalledWith(doc.id, 3)
expect(toastSpy).toHaveBeenCalledWith(
'Filled 1 of 2 fields. No value found for: Due'
)
expect(notifySpy).toHaveBeenCalledWith(doc.id)
expect(loadSpy).toHaveBeenCalledWith(doc.id, true, expect.any(Function))
expect(component.zoneOcrRunning()).toBe(true)
loadSpy.mock.calls[0][2]()
expect(component.zoneOcrRunning()).toBe(false)
expect(component.networkActive()).toBe(false)
})
it('should summarize zone OCR results', () => {
component.documentId.set(doc.id)
component.document.set(doc)
jest.spyOn(component as any, 'loadDocument').mockImplementation(() => {})
const runSpy = jest.spyOn(documentService, 'runZoneOcr')
const toastSpy = jest.spyOn(toastService, 'showInfo')
const modalSpy = jest.spyOn(modalService, 'open')
runSpy.mockReturnValueOnce(of({ status: 'completed', results: [] }))
component.runZoneOcr()
expect(modalSpy).not.toHaveBeenCalled()
expect(runSpy).toHaveBeenCalledWith(doc.id, null)
expect(toastSpy).toHaveBeenLastCalledWith(
'Zone OCR found no fields to fill.'
)
component.networkActive.set(false)
runSpy.mockReturnValueOnce(
of({
status: 'completed',
results: [
{ template: 'T', zone: 'Total', custom_field: 'Amount', value: 0 },
],
})
)
component.runZoneOcr()
expect(toastSpy).toHaveBeenLastCalledWith('Filled 1 of 1 fields.')
})
it('should confirm before running zone OCR over unsaved changes', () => {
component.documentId.set(doc.id)
component.document.set({ ...doc, __changedFields: ['title'] })
component.documentForm.patchValue({ title: 'Unsaved title' })
const runSpy = jest.spyOn(documentService, 'runZoneOcr')
const modalSpy = jest.spyOn(modalService, 'open')
component.runZoneOcr()
const modal = modalSpy.mock.results[0].value
expect(modal.componentInstance.message).toBe(
'Running Zone OCR will discard them.'
)
expect(runSpy).not.toHaveBeenCalled()
modal.componentInstance.cancel()
expect(runSpy).not.toHaveBeenCalled()
expect(component.documentForm.get('title').value).toBe('Unsaved title')
expect(component.zoneOcrRunning()).toBe(false)
})
it('should warn about unsaved changes in the zone OCR options dialog', () => {
component.documentId.set(doc.id)
component.document.set({ ...doc, __changedFields: ['title'] })
const runSpy = jest.spyOn(documentService, 'runZoneOcr')
const modalSpy = jest.spyOn(modalService, 'open')
component.runZoneOcrWithOptions()
const dialog = modalSpy.mock.results[0].value.componentInstance
expect(dialog.message).toBe('Running Zone OCR will discard them.')
expect(dialog.btnClass).toBe('btn-warning')
dialog.cancel()
expect(runSpy).not.toHaveBeenCalled()
component.networkActive.set(true)
component.runZoneOcrWithOptions()
expect(modalSpy).toHaveBeenCalledTimes(1)
})
it('should not allow saving or a second zone OCR run while one is running', () => {
component.documentId.set(doc.id)
component.document.set({ ...doc, __changedFields: ['title'] })
const pending = new Subject<any>()
const runSpy = jest
.spyOn(documentService, 'runZoneOcr')
.mockReturnValue(pending)
const modalSpy = jest.spyOn(modalService, 'open')
component.runZoneOcr()
modalSpy.mock.results[0].value.componentInstance.confirmClicked.emit()
expect(runSpy).toHaveBeenCalledWith(doc.id, null)
expect(component.zoneOcrRunning()).toBe(true)
expect(component.networkActive()).toBe(true)
const saveSpy = jest.spyOn(documentService, 'patch')
component.save()
expect(saveSpy).not.toHaveBeenCalled()
component.runZoneOcr()
expect(modalSpy).toHaveBeenCalledTimes(1)
expect(runSpy).toHaveBeenCalledTimes(1)
pending.error(new Error('failed'))
expect(component.zoneOcrRunning()).toBe(false)
expect(component.networkActive()).toBe(false)
expect(component.document().__changedFields).toEqual(['title'])
})
it('should not reload after zone OCR if another document is open', () => {
component.documentId.set(doc.id)
component.document.set({ ...doc, __changedFields: [] })
const pending = new Subject<any>()
jest.spyOn(documentService, 'runZoneOcr').mockReturnValue(pending)
const loadSpy = jest.spyOn(component as any, 'loadDocument')
component.runZoneOcr()
component.documentId.set(99)
pending.next({ results: [] })
expect(loadSpy).not.toHaveBeenCalled()
expect(component.zoneOcrRunning()).toBe(false)
pending.complete()
})
it('should keep unsaved changes when zone OCR is skipped', () => {
component.documentId.set(doc.id)
component.document.set({ ...doc, __changedFields: ['title'] })
component.documentForm.patchValue({ title: 'Unsaved title' })
const modalSpy = jest.spyOn(modalService, 'open')
jest
.spyOn(documentService, 'runZoneOcr')
.mockReturnValue(of({ status: 'skipped', results: [] }))
const loadSpy = jest.spyOn(component as any, 'loadDocument')
const infoSpy = jest.spyOn(toastService, 'showInfo')
component.runZoneOcr()
modalSpy.mock.results[0].value.componentInstance.confirm()
expect(loadSpy).not.toHaveBeenCalled()
expect(component.documentForm.get('title').value).toBe('Unsaved title')
expect(component.zoneOcrRunning()).toBe(false)
expect(component.networkActive()).toBe(false)
expect(infoSpy).toHaveBeenCalledWith(
'No OCR template applies to this document.'
)
})
it('should show an error when zone OCR fails', () => {
component.document.set(doc)
jest
.spyOn(documentService, 'runZoneOcr')
.mockReturnValue(throwError(() => new Error('nope')))
const loadSpy = jest.spyOn(component as any, 'loadDocument')
const errorSpy = jest.spyOn(toastService, 'showError')
component.runZoneOcr()
expect(errorSpy).toHaveBeenCalledWith(
'Error running zone OCR.',
expect.any(Error)
)
expect(loadSpy).not.toHaveBeenCalled()
expect(component.zoneOcrRunning()).toBe(false)
expect(component.networkActive()).toBe(false)
})
it('should change preview element by render type', () => { it('should change preview element by render type', () => {
initNormally() initNormally()
component.document.update((document) => ({ component.document.update((document) => ({
@@ -64,6 +64,7 @@ import {
FILTER_STORAGE_PATH, FILTER_STORAGE_PATH,
} from 'src/app/data/filter-rule-type' } from 'src/app/data/filter-rule-type'
import { ObjectWithId } from 'src/app/data/object-with-id' import { ObjectWithId } from 'src/app/data/object-with-id'
import { OcrZoneRunResult } from 'src/app/data/ocr-template'
import { StoragePath } from 'src/app/data/storage-path' import { StoragePath } from 'src/app/data/storage-path'
import { Tag } from 'src/app/data/tag' import { Tag } from 'src/app/data/tag'
import { SETTINGS_KEYS, SuggestionSource } from 'src/app/data/ui-settings' import { SETTINGS_KEYS, SuggestionSource } from 'src/app/data/ui-settings'
@@ -95,6 +96,7 @@ import { TagService } from 'src/app/services/rest/tag.service'
import { UserService } from 'src/app/services/rest/user.service' import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { TrashService } from 'src/app/services/trash.service'
import { WebsocketStatusService } from 'src/app/services/websocket-status.service' import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
import { getFilenameFromContentDisposition } from 'src/app/utils/http' import { getFilenameFromContentDisposition } from 'src/app/utils/http'
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter' import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
@@ -103,6 +105,7 @@ import { DocumentDetailFieldID } from '../admin/settings/settings.component'
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component' import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component' import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component'
import { ReprocessConfirmDialogComponent } from '../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component' import { ReprocessConfirmDialogComponent } from '../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component'
import { ZoneOcrConfirmDialogComponent } from '../common/confirm-dialog/zone-ocr-confirm-dialog/zone-ocr-confirm-dialog.component'
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component' import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component' import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component' import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
@@ -221,6 +224,7 @@ export class DocumentDetailComponent
private documentListViewService = inject(DocumentListViewService) private documentListViewService = inject(DocumentListViewService)
private documentTitlePipe = inject(DocumentTitlePipe) private documentTitlePipe = inject(DocumentTitlePipe)
private toastService = inject(ToastService) private toastService = inject(ToastService)
private trashService = inject(TrashService)
private settings = inject(SettingsService) private settings = inject(SettingsService)
private storagePathService = inject(StoragePathService) private storagePathService = inject(StoragePathService)
private permissionsService = inject(PermissionsService) private permissionsService = inject(PermissionsService)
@@ -264,6 +268,7 @@ export class DocumentDetailComponent
readonly error = signal<any>(undefined) readonly error = signal<any>(undefined)
readonly networkActive = signal(false) readonly networkActive = signal(false)
readonly zoneOcrRunning = signal(false)
readonly documentId = signal<number>(undefined) readonly documentId = signal<number>(undefined)
readonly document = signal<Document>(undefined) readonly document = signal<Document>(undefined)
@@ -541,7 +546,11 @@ export class DocumentDetailComponent
) )
} }
private loadDocument(documentId: number, forceRemote: boolean = false): void { private loadDocument(
documentId: number,
forceRemote: boolean = false,
onComplete?: () => void
): void {
let redirectedToRoot = false let redirectedToRoot = false
this.closeIncomingUpdateModal() this.closeIncomingUpdateModal()
this.pendingIncomingUpdate = null this.pendingIncomingUpdate = null
@@ -598,7 +607,8 @@ export class DocumentDetailComponent
}), }),
first(), first(),
takeUntil(this.unsubscribeNotifier), takeUntil(this.unsubscribeNotifier),
takeUntil(this.docChangeNotifier) takeUntil(this.docChangeNotifier),
finalize(() => onComplete?.())
) )
.subscribe({ .subscribe({
next: (doc) => { next: (doc) => {
@@ -1332,6 +1342,7 @@ export class DocumentDetailComponent
} }
save(close: boolean = false, savedCallback: () => void = null) { save(close: boolean = false, savedCallback: () => void = null) {
if (this.zoneOcrRunning()) return
this.networkActive.set(true) this.networkActive.set(true)
;(document.activeElement as HTMLElement)?.dispatchEvent(new Event('change')) ;(document.activeElement as HTMLElement)?.dispatchEvent(new Event('change'))
this.documentsService this.documentsService
@@ -1349,7 +1360,7 @@ export class DocumentDetailComponent
this.store.next(newValues) this.store.next(newValues)
this.openDocumentService.setDirty(this.document(), false) this.openDocumentService.setDirty(this.document(), false)
this.openDocumentService.save() this.openDocumentService.save()
this.toastService.showInfo( this.toastService.showSuccess(
$localize`Document "${newValues.title}" saved successfully.` $localize`Document "${newValues.title}" saved successfully.`
) )
this.networkActive.set(false) this.networkActive.set(false)
@@ -1377,7 +1388,7 @@ export class DocumentDetailComponent
if (!canEdit) { if (!canEdit) {
// document was 'given away' // document was 'given away'
this.openDocumentService.setDirty(this.document(), false) this.openDocumentService.setDirty(this.document(), false)
this.toastService.showInfo( this.toastService.showSuccess(
$localize`Document "${this.document().title}" saved successfully.` $localize`Document "${this.document().title}" saved successfully.`
) )
this.close() this.close()
@@ -1490,6 +1501,10 @@ export class DocumentDetailComponent
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({
next: () => { next: () => {
this.trashService.notifyMovedToTrash(
$localize`Moved "${this.document().title}" to the trash`,
[this.document().id]
)
modal.close() modal.close()
this.close() this.close()
}, },
@@ -1548,6 +1563,107 @@ export class DocumentDetailComponent
}) })
} }
/** Runs every template that matches, confirming only over unsaved changes. */
runZoneOcr() {
if (this.networkActive()) return
const documentId = this.documentId()
if (!this.hasLocalEdits(this.document())) {
this.executeZoneOcr(documentId, null)
return
}
const modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.title = $localize`Run Zone OCR`
modal.componentInstance.messageBold = $localize`You have unsaved changes.`
modal.componentInstance.message = $localize`Running Zone OCR will discard them.`
modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Run Zone OCR`
modal.componentInstance.confirmClicked
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
modal.close()
if (this.documentId() === documentId && !this.networkActive()) {
this.executeZoneOcr(documentId, null)
}
})
}
/** Opens the dialog to pick which template to run. */
runZoneOcrWithOptions() {
if (this.networkActive()) return
const documentId = this.documentId()
const modal = this.modalService.open(ZoneOcrConfirmDialogComponent, {
backdrop: 'static',
})
const dialog = modal.componentInstance as ZoneOcrConfirmDialogComponent
dialog.title = $localize`Run Zone OCR`
dialog.btnCaption = $localize`Run Zone OCR`
if (this.hasLocalEdits(this.document())) {
dialog.messageBold = $localize`You have unsaved changes.`
dialog.message = $localize`Running Zone OCR will discard them.`
dialog.btnClass = 'btn-warning'
} else {
dialog.message = $localize`Fields filled by the template will be overwritten.`
}
dialog.confirmClicked
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
modal.close()
if (this.documentId() === documentId && !this.networkActive()) {
this.executeZoneOcr(documentId, dialog.template)
}
})
}
private executeZoneOcr(documentId: number, templateId: number | null) {
this.zoneOcrRunning.set(true)
this.networkActive.set(true)
this.documentsService
.runZoneOcr(documentId, templateId)
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({
next: (response) => {
if (
this.documentId() !== documentId ||
response.status === 'skipped'
) {
this.finishZoneOcr()
if (response.status === 'skipped') {
this.toastService.showInfo(
$localize`No OCR template applies to this document.`
)
}
return
}
this.toastService.showInfo(this.zoneOcrSummary(response.results))
this.docChangeNotifier.next(documentId)
this.loadDocument(documentId, true, () => this.finishZoneOcr())
},
error: (error) => {
this.finishZoneOcr()
this.toastService.showError($localize`Error running zone OCR.`, error)
},
})
}
private zoneOcrSummary(results: OcrZoneRunResult[]): string {
if (!results.length) return $localize`Zone OCR found no fields to fill.`
const missed = results
.filter((result) => !`${result.value ?? ''}`.trim())
.map((result) => result.zone)
const filled = results.length - missed.length
return missed.length
? $localize`Filled ${filled} of ${results.length} fields. No value found for: ${missed.join(', ')}`
: $localize`Filled ${filled} of ${results.length} fields.`
}
private finishZoneOcr() {
this.zoneOcrRunning.set(false)
this.networkActive.set(false)
this.flushPendingIncomingUpdate()
}
private getSelectedNonLatestVersionId(): number | null { private getSelectedNonLatestVersionId(): number | null {
const versions = this.document()?.versions ?? [] const versions = this.document()?.versions ?? []
if (!versions.length || !this.selectedVersionId()) { if (!versions.length || !this.selectedVersionId()) {
@@ -33,7 +33,9 @@ describe('DocumentVersionDropdownComponent', () => {
| 'updateVersionLabel' | 'updateVersionLabel'
> >
> >
let toastService: jest.Mocked<Pick<ToastService, 'showError' | 'showInfo'>> let toastService: jest.Mocked<
Pick<ToastService, 'showError' | 'showInfo' | 'showSuccess'>
>
let finished$: Subject<{ taskId: string }> let finished$: Subject<{ taskId: string }>
let failed$: Subject<{ taskId: string; message?: string }> let failed$: Subject<{ taskId: string; message?: string }>
let modalService: jest.Mocked<Pick<NgbModal, 'open'>> let modalService: jest.Mocked<Pick<NgbModal, 'open'>>
@@ -55,6 +57,7 @@ describe('DocumentVersionDropdownComponent', () => {
toastService = { toastService = {
showError: jest.fn(), showError: jest.fn(),
showInfo: jest.fn(), showInfo: jest.fn(),
showSuccess: jest.fn(),
} }
permissionsService = { permissionsService = {
currentUserCan: jest.fn().mockReturnValue(true), currentUserCan: jest.fn().mockReturnValue(true),
@@ -416,6 +419,6 @@ describe('DocumentVersionDropdownComponent', () => {
expect(selectedEmitSpy).toHaveBeenCalledWith(2) expect(selectedEmitSpy).toHaveBeenCalledWith(2)
expect(component.newVersionLabel).toEqual('') expect(component.newVersionLabel).toEqual('')
expect(modal.close).toHaveBeenCalled() expect(modal.close).toHaveBeenCalled()
expect(toastService.showInfo).toHaveBeenCalled() expect(toastService.showSuccess).toHaveBeenCalled()
}) })
}) })
@@ -320,7 +320,7 @@ export class DocumentVersionDropdownComponent
} }
this.newVersionLabel = '' this.newVersionLabel = ''
modal.close() modal.close()
this.toastService.showInfo( this.toastService.showSuccess(
$localize`Existing document added as a version.` $localize`Existing document added as a version.`
) )
}, },
@@ -87,17 +87,22 @@
</button> </button>
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow"> <div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
<button ngbDropdownItem (click)="reprocessSelected()" [disabled]="!userCanEditAll && !userCanEditAll"> <button ngbDropdownItem (click)="reprocessSelected()" [disabled]="!userCanEditAll && !userCanEditAll">
<i-bs name="body-text" class="me-1"></i-bs><ng-container i18n>Reprocess</ng-container> <i-bs name="body-text" class="me-2"></i-bs><ng-container i18n>Reprocess</ng-container>
</button> </button>
<button ngbDropdownItem (click)="rotateSelected()" [disabled]="!userOwnsAll && !userCanEditAll"> <button ngbDropdownItem (click)="rotateSelected()" [disabled]="!userOwnsAll && !userCanEditAll">
<i-bs name="arrow-clockwise" class="me-1"></i-bs><ng-container i18n>Rotate</ng-container> <i-bs name="arrow-clockwise" class="me-2"></i-bs><ng-container i18n>Rotate</ng-container>
</button> </button>
<button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.allSelected || list.selectedCount < 2"> <button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.allSelected || list.selectedCount < 2">
<i-bs name="journals" class="me-1"></i-bs><ng-container i18n>Merge</ng-container> <i-bs name="journals" class="me-2"></i-bs><ng-container i18n>Merge</ng-container>
</button> </button>
<button ngbDropdownItem (click)="mergeSelectedAsVersions()" [disabled]="!userOwnsAll || !userCanEditAll || !userCanDelete || list.allSelected || list.selectedCount < 2"> <button ngbDropdownItem (click)="mergeSelectedAsVersions()" [disabled]="!userOwnsAll || !userCanEditAll || !userCanDelete || list.allSelected || list.selectedCount < 2">
<i-bs name="journal-bookmark-fill" class="me-1"></i-bs><ng-container i18n>Merge as versions</ng-container> <i-bs name="journal-bookmark-fill" class="me-2"></i-bs><ng-container i18n>Merge as versions</ng-container>
</button> </button>
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.OcrTemplate)) {
<button ngbDropdownItem (click)="runZoneOcrSelected()" [disabled]="!userCanEditAll">
<i-bs name="bounding-box-circles" class="me-2"></i-bs><ng-container i18n>Run Zone OCR</ng-container>
</button>
}
</div> </div>
</div> </div>
</div> </div>
@@ -116,12 +121,12 @@
<div ngbDropdownMenu aria-labelledby="dropdownSend" class="shadow"> <div ngbDropdownMenu aria-labelledby="dropdownSend" class="shadow">
@if (permissionService.currentUserCan(PermissionAction.Add, PermissionType.ShareLinkBundle)) { @if (permissionService.currentUserCan(PermissionAction.Add, PermissionType.ShareLinkBundle)) {
<button ngbDropdownItem (click)="createShareLinkBundle()" [disabled]="!canSendSelection"> <button ngbDropdownItem (click)="createShareLinkBundle()" [disabled]="!canSendSelection">
<i-bs name="link" class="me-1"></i-bs><ng-container i18n>Create a share link bundle</ng-container> <i-bs name="link" class="me-2"></i-bs><ng-container i18n>Create a share link bundle</ng-container>
</button> </button>
} }
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.ShareLinkBundle)) { @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.ShareLinkBundle)) {
<button ngbDropdownItem (click)="manageShareLinkBundles()"> <button ngbDropdownItem (click)="manageShareLinkBundles()">
<i-bs name="list-ul" class="me-1"></i-bs><ng-container i18n>Manage share link bundles</ng-container> <i-bs name="list-ul" class="me-2"></i-bs><ng-container i18n>Manage share link bundles</ng-container>
</button> </button>
} }
@if ( @if (
@@ -133,7 +138,7 @@
} }
@if (emailEnabled) { @if (emailEnabled) {
<button ngbDropdownItem (click)="emailSelected()" [disabled]="!canSendSelection"> <button ngbDropdownItem (click)="emailSelected()" [disabled]="!canSendSelection">
<i-bs name="envelope" class="me-1"></i-bs><ng-container i18n>Email</ng-container> <i-bs name="envelope" class="me-2"></i-bs><ng-container i18n>Email</ng-container>
</button> </button>
} }
</div> </div>
@@ -39,6 +39,7 @@ import { TagService } from 'src/app/services/rest/tag.service'
import { UserService } from 'src/app/services/rest/user.service' import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { TrashService } from 'src/app/services/trash.service'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component' import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component' import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
@@ -74,6 +75,7 @@ describe('BulkEditorComponent', () => {
let documentListViewService: DocumentListViewService let documentListViewService: DocumentListViewService
let documentService: DocumentService let documentService: DocumentService
let toastService: ToastService let toastService: ToastService
let trashService: TrashService
let modalService: NgbModal let modalService: NgbModal
let tagService: TagService let tagService: TagService
let correspondentsService: CorrespondentService let correspondentsService: CorrespondentService
@@ -185,6 +187,7 @@ describe('BulkEditorComponent', () => {
documentListViewService = TestBed.inject(DocumentListViewService) documentListViewService = TestBed.inject(DocumentListViewService)
documentService = TestBed.inject(DocumentService) documentService = TestBed.inject(DocumentService)
toastService = TestBed.inject(ToastService) toastService = TestBed.inject(ToastService)
trashService = TestBed.inject(TrashService)
modalService = TestBed.inject(NgbModal) modalService = TestBed.inject(NgbModal)
tagService = TestBed.inject(TagService) tagService = TestBed.inject(TagService)
correspondentsService = TestBed.inject(CorrespondentService) correspondentsService = TestBed.inject(CorrespondentService)
@@ -1164,10 +1167,14 @@ describe('BulkEditorComponent', () => {
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/delete/` `${environment.apiBaseUrl}documents/delete/`
) )
const notifySpy = jest
.spyOn(trashService, 'notifyMovedToTrash')
.mockImplementation(() => {})
req.flush(true) req.flush(true)
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
documents: [3, 4], documents: [3, 4],
}) })
expect(notifySpy).toHaveBeenCalledWith(expect.any(String), [3, 4])
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload ) // list reload
@@ -1177,10 +1184,19 @@ describe('BulkEditorComponent', () => {
component.showConfirmationDialogs = false component.showConfirmationDialogs = false
fixture.detectChanges() fixture.detectChanges()
jest
.spyOn(documentListViewService, 'allSelected', 'get')
.mockReturnValue(true)
component.applyDelete() component.applyDelete()
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/delete/` `${environment.apiBaseUrl}documents/delete/`
) )
req.flush(true)
// ids aren't known when all matching documents are selected, so no undo
expect(notifySpy).toHaveBeenLastCalledWith(expect.any(String), null)
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
}) })
it('should not be accessible with insufficient global permissions', () => { it('should not be accessible with insufficient global permissions', () => {
@@ -1270,6 +1286,53 @@ describe('BulkEditorComponent', () => {
) // listAllFilteredIds ) // listAllFilteredIds
}) })
it('should support run zone ocr', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[0]))
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest
.spyOn(documentListViewService, 'documents', 'get')
.mockReturnValue([{ id: 3 }, { id: 4 }])
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([3, 4]))
jest
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
.mockReturnValue(true)
fixture.detectChanges()
component.runZoneOcrSelected()
expect(modal).not.toBeUndefined()
modal.componentInstance.confirm()
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/run_zone_ocr/`
)
req.flush(true)
expect(req.request.body).toEqual({
documents: [3, 4],
})
component.runZoneOcrSelected()
modal.componentInstance.template = 3
modal.componentInstance.confirm()
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/run_zone_ocr/`
)
req.flush(true)
expect(req.request.body).toEqual({
documents: [3, 4],
template: 3,
})
httpTestingController.match(
`${environment.apiBaseUrl}ocr_templates/?page=1&page_size=100000`
) // template list
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
})
it('should support rotate', () => { it('should support rotate', () => {
let modal: NgbModalRef let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[0])) modalService.activeInstances.subscribe((m) => (modal = m[0]))
@@ -1416,7 +1479,7 @@ describe('BulkEditorComponent', () => {
const mergeAsVersionsSpy = jest const mergeAsVersionsSpy = jest
.spyOn(documentService, 'mergeDocumentsAsVersions') .spyOn(documentService, 'mergeDocumentsAsVersions')
.mockReturnValue(of(true)) .mockReturnValue(of(true))
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
fixture.detectChanges() fixture.detectChanges()
component.mergeSelectedAsVersions() component.mergeSelectedAsVersions()
@@ -1432,7 +1495,9 @@ describe('BulkEditorComponent', () => {
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) )
expect(documentListViewService.selected.size).toEqual(0) expect(documentListViewService.selected.size).toEqual(0)
expect(toastInfoSpy).toHaveBeenCalledWith('Documents merged as versions.') expect(toastSuccessSpy).toHaveBeenCalledWith(
'Documents merged as versions.'
)
}) })
it('should not report success when merging documents as versions fails', () => { it('should not report success when merging documents as versions fails', () => {
@@ -1458,7 +1523,7 @@ describe('BulkEditorComponent', () => {
jest jest
.spyOn(documentService, 'mergeDocumentsAsVersions') .spyOn(documentService, 'mergeDocumentsAsVersions')
.mockReturnValue(throwError(() => new Error('failed'))) .mockReturnValue(throwError(() => new Error('failed')))
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
fixture.detectChanges() fixture.detectChanges()
@@ -1467,7 +1532,7 @@ describe('BulkEditorComponent', () => {
modal.componentInstance.confirm() modal.componentInstance.confirm()
expect(toastErrorSpy).toHaveBeenCalled() expect(toastErrorSpy).toHaveBeenCalled()
expect(toastInfoSpy).not.toHaveBeenCalled() expect(toastSuccessSpy).not.toHaveBeenCalled()
}) })
it('should support bulk download with archive, originals or both and file formatting', () => { it('should support bulk download with archive, originals or both and file formatting', () => {
@@ -1846,7 +1911,7 @@ describe('BulkEditorComponent', () => {
.spyOn(documentListViewService, 'selected', 'get') .spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([3, 4])) .mockReturnValue(new Set([3, 4]))
fixture.detectChanges() fixture.detectChanges()
const toastServiceShowInfoSpy = jest.spyOn(toastService, 'showInfo') const toastServiceShowInfoSpy = jest.spyOn(toastService, 'showSuccess')
const toastServiceShowErrorSpy = jest.spyOn(toastService, 'showError') const toastServiceShowErrorSpy = jest.spyOn(toastService, 'showError')
const listReloadSpy = jest.spyOn(documentListViewService, 'reload') const listReloadSpy = jest.spyOn(documentListViewService, 'reload')
@@ -20,7 +20,15 @@ import {
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { saveAs } from 'file-saver' import { saveAs } from 'file-saver'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { first, map, Observable, Subject, switchMap, takeUntil } from 'rxjs' import {
first,
map,
Observable,
Subject,
switchMap,
takeUntil,
tap,
} from 'rxjs'
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component' import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
import { CustomField } from 'src/app/data/custom-field' import { CustomField } from 'src/app/data/custom-field'
import { MatchingModel } from 'src/app/data/matching-model' import { MatchingModel } from 'src/app/data/matching-model'
@@ -49,12 +57,14 @@ import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { TagService } from 'src/app/services/rest/tag.service' import { TagService } from 'src/app/services/rest/tag.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { TrashService } from 'src/app/services/trash.service'
import { flattenTags } from 'src/app/utils/flatten-tags' import { flattenTags } from 'src/app/utils/flatten-tags'
import { queryParamsFromFilterRules } from 'src/app/utils/query-params' import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
import { MergeAsVersionsConfirmDialogComponent } from '../../common/confirm-dialog/merge-as-versions-confirm-dialog/merge-as-versions-confirm-dialog.component' import { MergeAsVersionsConfirmDialogComponent } from '../../common/confirm-dialog/merge-as-versions-confirm-dialog/merge-as-versions-confirm-dialog.component'
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component' import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
import { ReprocessConfirmDialogComponent } from '../../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component' import { ReprocessConfirmDialogComponent } from '../../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component'
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component' import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
import { ZoneOcrConfirmDialogComponent } from '../../common/confirm-dialog/zone-ocr-confirm-dialog/zone-ocr-confirm-dialog.component'
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component' import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component' import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component' import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
@@ -99,6 +109,7 @@ export class BulkEditorComponent
private openDocumentService = inject(OpenDocumentsService) private openDocumentService = inject(OpenDocumentsService)
private settings = inject(SettingsService) private settings = inject(SettingsService)
private toastService = inject(ToastService) private toastService = inject(ToastService)
private trashService = inject(TrashService)
private storagePathService = inject(StoragePathService) private storagePathService = inject(StoragePathService)
private customFieldService = inject(CustomFieldsService) private customFieldService = inject(CustomFieldsService)
public readonly permissionService = inject(PermissionsService) public readonly permissionService = inject(PermissionsService)
@@ -306,7 +317,7 @@ export class BulkEditorComponent
next: () => { next: () => {
this.handleOperationSuccess(modal, options.clearSelection ?? false) this.handleOperationSuccess(modal, options.clearSelection ?? false)
if (options.successMessage) { if (options.successMessage) {
this.toastService.showInfo(options.successMessage) this.toastService.showSuccess(options.successMessage)
} }
}, },
error: (error) => this.handleOperationError(modal, error), error: (error) => this.handleOperationError(modal, error),
@@ -884,19 +895,33 @@ export class BulkEditorComponent
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => { .subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled.set(false)
this.executeDocumentAction( this.deleteSelected(modal)
modal,
this.documentService.deleteDocuments(this.getSelectionQuery())
)
}) })
} else { } else {
this.executeDocumentAction( this.deleteSelected(null)
null,
this.documentService.deleteDocuments(this.getSelectionQuery())
)
} }
} }
private deleteSelected(modal: NgbModalRef) {
const documents = this.list.allSelected
? null
: Array.from(this.list.selected)
const count = this.getSelectionSize()
this.executeDocumentAction(
modal,
this.documentService
.deleteDocuments(this.getSelectionQuery())
.pipe(
tap(() =>
this.trashService.notifyMovedToTrash(
$localize`Moved ${count} document(s) to the trash`,
documents
)
)
)
)
}
downloadSelected() { downloadSelected() {
this.awaitingDownload.set(true) this.awaitingDownload.set(true)
let downloadFileType: string = let downloadFileType: string =
@@ -942,6 +967,30 @@ export class BulkEditorComponent
}) })
} }
runZoneOcrSelected() {
const modal = this.modalService.open(ZoneOcrConfirmDialogComponent, {
backdrop: 'static',
})
const dialog = modal.componentInstance as ZoneOcrConfirmDialogComponent
dialog.title = $localize`Run zone OCR confirm`
dialog.messageBold = $localize`This operation will run zone OCR on ${this.getSelectionSize()} selected document(s).`
dialog.message = $localize`Fields filled by the template will be overwritten.`
dialog.btnClass = 'btn-warning'
dialog.btnCaption = $localize`Proceed`
dialog.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
dialog.buttonsEnabled.set(false)
this.executeDocumentAction(
modal,
this.documentService.runZoneOcrDocuments(
this.getSelectionQuery(),
dialog.template
)
)
})
}
setPermissions() { setPermissions() {
let modal = this.modalService.open(PermissionsDialogComponent, { let modal = this.modalService.open(PermissionsDialogComponent, {
backdrop: 'static', backdrop: 'static',
@@ -1065,7 +1114,7 @@ export class BulkEditorComponent
dialog.selection = this.getSelectionQuery() dialog.selection = this.getSelectionQuery()
dialog.selectionCount = this.getSelectionSize() dialog.selectionCount = this.getSelectionSize()
dialog.succeeded.subscribe((result) => { dialog.succeeded.subscribe((result) => {
this.toastService.showInfo($localize`Custom fields updated.`) this.toastService.showSuccess($localize`Custom fields updated.`)
this.list.reload() this.list.reload()
this.list.reduceSelectionToFilter() this.list.reduceSelectionToFilter()
this.list.selected.forEach((id) => { this.list.selected.forEach((id) => {
@@ -487,7 +487,7 @@ describe('DocumentListComponent', () => {
delete modifiedView.name delete modifiedView.name
const savedViewServicePatch = jest.spyOn(savedViewService, 'patch') const savedViewServicePatch = jest.spyOn(savedViewService, 'patch')
savedViewServicePatch.mockReturnValue(of(modifiedView)) savedViewServicePatch.mockReturnValue(of(modifiedView))
const toastSpy = jest.spyOn(toastService, 'showInfo') const toastSpy = jest.spyOn(toastService, 'showSuccess')
component.saveViewConfig() component.saveViewConfig()
expect(savedViewServicePatch).toHaveBeenCalledWith(modifiedView) expect(savedViewServicePatch).toHaveBeenCalledWith(modifiedView)
@@ -592,7 +592,7 @@ describe('DocumentListComponent', () => {
let openModal: NgbModalRef let openModal: NgbModalRef
modalService.activeInstances.subscribe((modal) => (openModal = modal[0])) modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
const modalSpy = jest.spyOn(modalService, 'open') const modalSpy = jest.spyOn(modalService, 'open')
const toastSpy = jest.spyOn(toastService, 'showInfo') const toastSpy = jest.spyOn(toastService, 'showSuccess')
const savedViewServiceCreate = jest.spyOn(savedViewService, 'create') const savedViewServiceCreate = jest.spyOn(savedViewService, 'create')
jest jest
.spyOn(savedViewService, 'dashboardViews', 'get') .spyOn(savedViewService, 'dashboardViews', 'get')
@@ -418,7 +418,7 @@ export class DocumentListComponent
next: (view) => { next: (view) => {
this.activeSavedView = view this.activeSavedView = view
this.unmodifiedSavedView = view this.unmodifiedSavedView = view
this.toastService.showInfo( this.toastService.showSuccess(
$localize`View "${this.list.activeSavedViewTitle}" saved successfully.` $localize`View "${this.list.activeSavedViewTitle}" saved successfully.`
) )
this.unmodifiedFilterRules.set(this.list.filterRules) this.unmodifiedFilterRules.set(this.list.filterRules)
@@ -493,7 +493,7 @@ export class DocumentListComponent
.subscribe({ .subscribe({
next: () => { next: () => {
modal.close() modal.close()
this.toastService.showInfo( this.toastService.showSuccess(
$localize`View "${savedView.name}" created successfully.` $localize`View "${savedView.name}" created successfully.`
) )
}, },
@@ -119,7 +119,7 @@ describe('FileDropComponent', () => {
} }
component.onDrop(dragEvent as DragEvent) component.onDrop(dragEvent as DragEvent)
advanceTimers(3000) advanceTimers(3000)
expect(toastSpy).toHaveBeenCalled() expect(toastSpy).not.toHaveBeenCalled()
expect(uploadSpy).toHaveBeenCalled() expect(uploadSpy).toHaveBeenCalled()
}) })
@@ -159,7 +159,7 @@ describe('FileDropComponent', () => {
component.onDrop(dragEvent as DragEvent) component.onDrop(dragEvent as DragEvent)
await flushPromises() await flushPromises()
advanceTimers(3000) advanceTimers(3000)
expect(toastSpy).toHaveBeenCalled() expect(toastSpy).not.toHaveBeenCalled()
expect(uploadSpy).toHaveBeenCalled() expect(uploadSpy).toHaveBeenCalled()
}) })
@@ -227,7 +227,7 @@ describe('FileDropComponent', () => {
} }
component.onDrop(dragEvent as DragEvent) component.onDrop(dragEvent as DragEvent)
advanceTimers(3000) advanceTimers(3000)
expect(toastSpy).toHaveBeenCalled() expect(toastSpy).not.toHaveBeenCalled()
expect(uploadSpy).toHaveBeenCalled() expect(uploadSpy).toHaveBeenCalled()
}) })
@@ -134,8 +134,7 @@ export class FileDropComponent {
Promise.all(promises) Promise.all(promises)
.then((results) => { .then((results) => {
files.push(...[].concat(...results)) files.push(...[].concat(...results))
this.toastService.showInfo($localize`Initiating upload...`, 3000) this.uploadDocumentsService.uploadFiles(files)
files.forEach((file) => this.uploadDocumentsService.uploadFile(file))
}) })
.catch((e) => { .catch((e) => {
this.toastService.showError( this.toastService.showError(
@@ -143,8 +142,7 @@ export class FileDropComponent {
) )
}) })
} else if (files.length) { } else if (files.length) {
this.toastService.showInfo($localize`Initiating upload...`, 3000) this.uploadDocumentsService.uploadFiles(files)
files.forEach((file) => this.uploadDocumentsService.uploadFile(file))
} }
this.onDragLeave(event, true) this.onDragLeave(event, true)
@@ -107,7 +107,7 @@ describe('CustomFieldsComponent', () => {
let modal: NgbModalRef let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1])) modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const reloadSpy = jest.spyOn(component, 'reload') const reloadSpy = jest.spyOn(component, 'reload')
component.editField() component.editField()
@@ -131,7 +131,7 @@ describe('CustomFieldsComponent', () => {
let modal: NgbModalRef let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1])) modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const reloadSpy = jest.spyOn(component, 'reload') const reloadSpy = jest.spyOn(component, 'reload')
const editButton = fixture.debugElement const editButton = fixture.debugElement
@@ -82,7 +82,9 @@ export class CustomFieldsComponent
modal.componentInstance.succeeded modal.componentInstance.succeeded
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((newField) => { .subscribe((newField) => {
this.toastService.showInfo($localize`Saved field "${newField.name}".`) this.toastService.showSuccess(
$localize`Saved field "${newField.name}".`
)
this.customFieldsService.clearCache() this.customFieldsService.clearCache()
this.settingsService.initializeDisplayFields() this.settingsService.initializeDisplayFields()
this.documentService.reload() this.documentService.reload()
@@ -109,7 +111,9 @@ export class CustomFieldsComponent
this.customFieldsService.delete(field).subscribe({ this.customFieldsService.delete(field).subscribe({
next: () => { next: () => {
modal.close() modal.close()
this.toastService.showInfo($localize`Deleted field "${field.name}"`) this.toastService.showSuccess(
$localize`Deleted field "${field.name}"`
)
this.customFieldsService.clearCache() this.customFieldsService.clearCache()
this.settingsService.initializeDisplayFields() this.settingsService.initializeDisplayFields()
this.documentService.reload() this.documentService.reload()
@@ -160,7 +160,7 @@ describe('ManagementListComponent', () => {
let modal: NgbModalRef let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1])) modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const reloadSpy = jest.spyOn(component, 'reloadData') const reloadSpy = jest.spyOn(component, 'reloadData')
component.openCreateDialog() component.openCreateDialog()
@@ -183,7 +183,7 @@ describe('ManagementListComponent', () => {
let modal: NgbModalRef let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1])) modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const reloadSpy = jest.spyOn(component, 'reloadData') const reloadSpy = jest.spyOn(component, 'reloadData')
component.openEditDialog(tags[0]) component.openEditDialog(tags[0])
@@ -387,7 +387,7 @@ describe('ManagementListComponent', () => {
expect(bulkEditPermsSpy).toHaveBeenCalled() expect(bulkEditPermsSpy).toHaveBeenCalled()
expect(errorToastSpy).toHaveBeenCalled() expect(errorToastSpy).toHaveBeenCalled()
const successToastSpy = jest.spyOn(toastService, 'showInfo') const successToastSpy = jest.spyOn(toastService, 'showSuccess')
bulkEditPermsSpy.mockReturnValueOnce(of('OK')) bulkEditPermsSpy.mockReturnValueOnce(of('OK'))
modal.componentInstance.confirmClicked.emit({ modal.componentInstance.confirmClicked.emit({
permissions: {}, permissions: {},
@@ -452,7 +452,7 @@ describe('ManagementListComponent', () => {
) )
expect(errorToastSpy).toHaveBeenCalled() expect(errorToastSpy).toHaveBeenCalled()
const successToastSpy = jest.spyOn(toastService, 'showInfo') const successToastSpy = jest.spyOn(toastService, 'showSuccess')
bulkEditSpy.mockReturnValueOnce(of('OK')) bulkEditSpy.mockReturnValueOnce(of('OK'))
modal.componentInstance.confirmClicked.emit(null) modal.componentInstance.confirmClicked.emit(null)
expect(bulkEditSpy).toHaveBeenCalled() expect(bulkEditSpy).toHaveBeenCalled()
@@ -224,7 +224,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
activeModal.componentInstance.dialogMode.set(EditDialogMode.CREATE) activeModal.componentInstance.dialogMode.set(EditDialogMode.CREATE)
activeModal.componentInstance.succeeded.subscribe(() => { activeModal.componentInstance.succeeded.subscribe(() => {
this.reloadData() this.reloadData()
this.toastService.showInfo( this.toastService.showSuccess(
$localize`Successfully created ${this.typeName}.` $localize`Successfully created ${this.typeName}.`
) )
}) })
@@ -244,7 +244,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
activeModal.componentInstance.dialogMode.set(EditDialogMode.EDIT) activeModal.componentInstance.dialogMode.set(EditDialogMode.EDIT)
activeModal.componentInstance.succeeded.subscribe(() => { activeModal.componentInstance.succeeded.subscribe(() => {
this.reloadData() this.reloadData()
this.toastService.showInfo( this.toastService.showSuccess(
$localize`Successfully updated ${this.typeName} "${object.name}".` $localize`Successfully updated ${this.typeName} "${object.name}".`
) )
}) })
@@ -428,7 +428,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
.subscribe({ .subscribe({
next: () => { next: () => {
modal.close() modal.close()
this.toastService.showInfo( this.toastService.showSuccess(
$localize`Permissions updated successfully` $localize`Permissions updated successfully`
) )
this.reloadData() this.reloadData()
@@ -468,7 +468,9 @@ export abstract class ManagementListComponent<T extends MatchingModel>
.subscribe({ .subscribe({
next: () => { next: () => {
modal.close() modal.close()
this.toastService.showInfo($localize`Objects deleted successfully`) this.toastService.showSuccess(
$localize`Objects deleted successfully`
)
this.reloadData() this.reloadData()
}, },
error: (error) => { error: (error) => {
@@ -184,7 +184,7 @@ describe('MailComponent', () => {
component.editMailAccount(mailAccounts[0] as MailAccount) component.editMailAccount(mailAccounts[0] as MailAccount)
let editDialog = modal.componentInstance as MailAccountEditDialogComponent let editDialog = modal.componentInstance as MailAccountEditDialogComponent
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
editDialog.failed.emit() editDialog.failed.emit()
expect(toastErrorSpy).toHaveBeenCalled() expect(toastErrorSpy).toHaveBeenCalled()
editDialog.succeeded.emit(mailAccounts[0] as any) editDialog.succeeded.emit(mailAccounts[0] as any)
@@ -203,7 +203,7 @@ describe('MailComponent', () => {
const deleteDialog = modal.componentInstance as ConfirmDialogComponent const deleteDialog = modal.componentInstance as ConfirmDialogComponent
const deleteSpy = jest.spyOn(mailAccountService, 'delete') const deleteSpy = jest.spyOn(mailAccountService, 'delete')
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const listAllSpy = jest.spyOn(mailAccountService, 'listAll') const listAllSpy = jest.spyOn(mailAccountService, 'listAll')
deleteSpy.mockReturnValueOnce( deleteSpy.mockReturnValueOnce(
throwError(() => new Error('error deleting mail account')) throwError(() => new Error('error deleting mail account'))
@@ -242,7 +242,7 @@ describe('MailComponent', () => {
component.editMailRule(mailRules[0] as MailRule) component.editMailRule(mailRules[0] as MailRule)
const editDialog = modal.componentInstance as MailRuleEditDialogComponent const editDialog = modal.componentInstance as MailRuleEditDialogComponent
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
editDialog.failed.emit() editDialog.failed.emit()
expect(toastErrorSpy).toHaveBeenCalled() expect(toastErrorSpy).toHaveBeenCalled()
editDialog.succeeded.emit(mailRules[0] as any) editDialog.succeeded.emit(mailRules[0] as any)
@@ -272,7 +272,7 @@ describe('MailComponent', () => {
const deleteDialog = modal.componentInstance as ConfirmDialogComponent const deleteDialog = modal.componentInstance as ConfirmDialogComponent
const deleteSpy = jest.spyOn(mailRuleService, 'delete') const deleteSpy = jest.spyOn(mailRuleService, 'delete')
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const listAllSpy = jest.spyOn(mailRuleService, 'listAll') const listAllSpy = jest.spyOn(mailRuleService, 'listAll')
deleteSpy.mockReturnValueOnce( deleteSpy.mockReturnValueOnce(
throwError(() => new Error('error deleting mail rule "rule1"')) throwError(() => new Error('error deleting mail rule "rule1"'))
@@ -303,7 +303,7 @@ describe('MailComponent', () => {
let modal: NgbModalRef let modal: NgbModalRef
modalService.activeInstances.subscribe((refs) => (modal = refs[0])) modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
const rulePatchSpy = jest.spyOn(mailRuleService, 'patch') const rulePatchSpy = jest.spyOn(mailRuleService, 'patch')
component.editPermissions(mailRules[0] as MailRule) component.editPermissions(mailRules[0] as MailRule)
expect(modal).not.toBeUndefined() expect(modal).not.toBeUndefined()
@@ -356,7 +356,7 @@ describe('MailComponent', () => {
By.css('input[type="checkbox"]') By.css('input[type="checkbox"]')
) )
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
// fail first // fail first
patchSpy.mockReturnValueOnce( patchSpy.mockReturnValueOnce(
throwError(() => new Error('Error getting config')) throwError(() => new Error('Error getting config'))
@@ -380,7 +380,7 @@ describe('MailComponent', () => {
jest jest
.spyOn(activatedRoute, 'queryParamMap', 'get') .spyOn(activatedRoute, 'queryParamMap', 'get')
.mockReturnValue(of(convertToParamMap(queryParams))) .mockReturnValue(of(convertToParamMap(queryParams)))
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
completeSetup() completeSetup()
expect(toastInfoSpy).toHaveBeenCalled() expect(toastInfoSpy).toHaveBeenCalled()
}) })
@@ -135,7 +135,9 @@ export class MailComponent
if (params.get('oauth_success')) { if (params.get('oauth_success')) {
const success = params.get('oauth_success') === '1' const success = params.get('oauth_success') === '1'
if (success) { if (success) {
this.toastService.showInfo($localize`OAuth2 authentication success`) this.toastService.showSuccess(
$localize`OAuth2 authentication success`
)
this.oAuthAccountId = parseInt(params.get('account_id')) this.oAuthAccountId = parseInt(params.get('account_id'))
if (this.mailAccounts().length > 0) { if (this.mailAccounts().length > 0) {
this.editMailAccount( this.editMailAccount(
@@ -169,7 +171,7 @@ export class MailComponent
modal.componentInstance.succeeded modal.componentInstance.succeeded
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((newMailAccount) => { .subscribe((newMailAccount) => {
this.toastService.showInfo( this.toastService.showSuccess(
$localize`Saved account "${newMailAccount.name}".` $localize`Saved account "${newMailAccount.name}".`
) )
this.mailAccountService.clearCache() this.mailAccountService.clearCache()
@@ -200,7 +202,7 @@ export class MailComponent
this.mailAccountService.delete(account).subscribe({ this.mailAccountService.delete(account).subscribe({
next: () => { next: () => {
modal.close() modal.close()
this.toastService.showInfo( this.toastService.showSuccess(
$localize`Deleted mail account "${account.name}"` $localize`Deleted mail account "${account.name}"`
) )
this.mailAccountService.clearCache() this.mailAccountService.clearCache()
@@ -248,7 +250,9 @@ export class MailComponent
modal.componentInstance.succeeded modal.componentInstance.succeeded
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((newMailRule) => { .subscribe((newMailRule) => {
this.toastService.showInfo($localize`Saved rule "${newMailRule.name}".`) this.toastService.showSuccess(
$localize`Saved rule "${newMailRule.name}".`
)
this.mailRuleService.clearCache() this.mailRuleService.clearCache()
this.mailRuleService this.mailRuleService
.listAll(null, null, { full_perms: true }) .listAll(null, null, { full_perms: true })
@@ -273,7 +277,7 @@ export class MailComponent
onMailRuleEnableToggled(rule: MailRule) { onMailRuleEnableToggled(rule: MailRule) {
this.mailRuleService.patch(rule).subscribe({ this.mailRuleService.patch(rule).subscribe({
next: () => { next: () => {
this.toastService.showInfo( this.toastService.showSuccess(
rule.enabled rule.enabled
? $localize`Rule "${rule.name}" enabled.` ? $localize`Rule "${rule.name}" enabled.`
: $localize`Rule "${rule.name}" disabled.` : $localize`Rule "${rule.name}" disabled.`
@@ -302,7 +306,7 @@ export class MailComponent
this.mailRuleService.delete(rule).subscribe({ this.mailRuleService.delete(rule).subscribe({
next: () => { next: () => {
modal.close() modal.close()
this.toastService.showInfo( this.toastService.showSuccess(
$localize`Deleted mail rule "${rule.name}"` $localize`Deleted mail rule "${rule.name}"`
) )
this.mailRuleService.clearCache() this.mailRuleService.clearCache()
@@ -338,7 +342,7 @@ export class MailComponent
object['set_permissions'] = permissions['set_permissions'] object['set_permissions'] = permissions['set_permissions']
service.patch(object).subscribe({ service.patch(object).subscribe({
next: () => { next: () => {
this.toastService.showInfo($localize`Permissions updated`) this.toastService.showSuccess($localize`Permissions updated`)
modal.close() modal.close()
}, },
error: (e) => { error: (e) => {
Loaded 100 of 176 files, more files were not shown because too many files have changed in this diff. Show more