mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-21 07:18:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
475e006edb | ||
|
|
24002e259d | ||
|
|
c61b322632 | ||
|
|
bdef6009c3 | ||
|
|
b2d1bcf221 | ||
|
|
20e7fd99f1 | ||
|
|
9ce52a3957 | ||
|
|
1de0da8491 |
No files matched your search
@@ -39,6 +39,7 @@ ARG RUNTIME_PACKAGES="\
|
|||||||
ghostscript \
|
ghostscript \
|
||||||
gnupg \
|
gnupg \
|
||||||
icc-profiles-free \
|
icc-profiles-free \
|
||||||
|
imagemagick \
|
||||||
# PostgreSQL
|
# PostgreSQL
|
||||||
postgresql-client \
|
postgresql-client \
|
||||||
# MySQL / MariaDB
|
# MySQL / MariaDB
|
||||||
@@ -92,6 +93,21 @@ RUN set -eux \
|
|||||||
https://github.com/paperless-ngx/builder/releases/download/jbig2enc-trixie-v${JBIG2ENC_VERSION}/jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \
|
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="\
|
||||||
|
|||||||
@@ -111,7 +111,10 @@ jobs:
|
|||||||
timeout-minutes: 12
|
timeout-minutes: 12
|
||||||
uses: $/.github/actions/apt-install
|
uses: $/.github/actions/apt-install
|
||||||
with:
|
with:
|
||||||
packages: unpaper tesseract-ocr ghostscript poppler-utils qpdf
|
packages: unpaper tesseract-ocr imagemagick ghostscript poppler-utils
|
||||||
|
- name: Configure ImageMagick
|
||||||
|
run: |
|
||||||
|
sudo cp docker/rootfs/etc/ImageMagick-6/paperless-policy.xml /etc/ImageMagick-7/policy.xml
|
||||||
- name: Install Python dependencies
|
- name: Install Python dependencies
|
||||||
env:
|
env:
|
||||||
PYTHON_VERSION: ${{ steps.setup-python.outputs.python-version }}
|
PYTHON_VERSION: ${{ steps.setup-python.outputs.python-version }}
|
||||||
|
|||||||
@@ -76,12 +76,11 @@ jobs:
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reaching here with the honeypot means AI usage was not disclosed in the form, so that
|
||||||
|
// message can name the cause.
|
||||||
// A missing template label only tells us the issue did not come from the form, which
|
// A missing template label only tells us the issue did not come from the form, which
|
||||||
// is reason enough to close it, but not proof of how it was written. It takes priority:
|
// is reason enough to close it, but not proof of how it was written.
|
||||||
// a hand-built body can carry the honeypot *and* a disclosure, and the form message is
|
const body = honeypot
|
||||||
// the only one that is accurate for both. Reaching the honeypot message means the issue
|
|
||||||
// came from the form without disclosing AI usage, so that message can name the cause.
|
|
||||||
const body = honeypot && !bypassedTemplate
|
|
||||||
? 'This issue was automatically closed because it contains a marker that is only visible to ' +
|
? 'This issue was automatically closed because it contains a marker that is only visible to ' +
|
||||||
'automated tools, which indicates it was generated by an AI agent, but the "AI usage" field does not ' +
|
'automated tools, which indicates it was generated by an AI agent, but the "AI usage" field does not ' +
|
||||||
'disclose this.\n\n' +
|
'disclose this.\n\n' +
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ ARG RUNTIME_PACKAGES="\
|
|||||||
ghostscript \
|
ghostscript \
|
||||||
gnupg \
|
gnupg \
|
||||||
icc-profiles-free \
|
icc-profiles-free \
|
||||||
|
imagemagick \
|
||||||
# PostgreSQL
|
# PostgreSQL
|
||||||
postgresql-client \
|
postgresql-client \
|
||||||
# MySQL / MariaDB
|
# MySQL / MariaDB
|
||||||
@@ -166,6 +167,8 @@ RUN set -eux \
|
|||||||
https://github.com/paperless-ngx/builder/releases/download/jbig2enc-trixie-v${JBIG2ENC_VERSION}/jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \
|
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/* \
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE policymap [
|
||||||
|
<!ELEMENT policymap (policy)+>
|
||||||
|
<!ATTLIST policymap xmlns CDATA #FIXED ''>
|
||||||
|
<!ELEMENT policy EMPTY>
|
||||||
|
<!ATTLIST policy xmlns CDATA #FIXED '' domain NMTOKEN #REQUIRED
|
||||||
|
name NMTOKEN #IMPLIED pattern CDATA #IMPLIED rights NMTOKEN #IMPLIED
|
||||||
|
stealth NMTOKEN #IMPLIED value CDATA #IMPLIED>
|
||||||
|
]>
|
||||||
|
<!--
|
||||||
|
Configure ImageMagick policies.
|
||||||
|
|
||||||
|
Domains include system, delegate, coder, filter, path, or resource.
|
||||||
|
|
||||||
|
Rights include none, read, write, execute and all. Use | to combine them,
|
||||||
|
for example: "read | write" to permit read from, or write to, a path.
|
||||||
|
|
||||||
|
Use a glob expression as a pattern.
|
||||||
|
|
||||||
|
Suppose we do not want users to process MPEG video images:
|
||||||
|
|
||||||
|
<policy domain="delegate" rights="none" pattern="mpeg:decode" />
|
||||||
|
|
||||||
|
Here we do not want users reading images from HTTP:
|
||||||
|
|
||||||
|
<policy domain="coder" rights="none" pattern="HTTP" />
|
||||||
|
|
||||||
|
The /repository file system is restricted to read only. We use a glob
|
||||||
|
expression to match all paths that start with /repository:
|
||||||
|
|
||||||
|
<policy domain="path" rights="read" pattern="/repository/*" />
|
||||||
|
|
||||||
|
Lets prevent users from executing any image filters:
|
||||||
|
|
||||||
|
<policy domain="filter" rights="none" pattern="*" />
|
||||||
|
|
||||||
|
Any large image is cached to disk rather than memory:
|
||||||
|
|
||||||
|
<policy domain="resource" name="area" value="1GP"/>
|
||||||
|
|
||||||
|
Define arguments for the memory, map, area, width, height and disk resources
|
||||||
|
with SI prefixes (.e.g 100MB). In addition, resource policies are maximums
|
||||||
|
for each instance of ImageMagick (e.g. policy memory limit 1GB, -limit 2GB
|
||||||
|
exceeds policy maximum so memory limit is 1GB).
|
||||||
|
|
||||||
|
Rules are processed in order. Here we want to restrict ImageMagick to only
|
||||||
|
read or write a small subset of proven web-safe image types:
|
||||||
|
|
||||||
|
<policy domain="delegate" rights="none" pattern="*" />
|
||||||
|
<policy domain="filter" rights="none" pattern="*" />
|
||||||
|
<policy domain="coder" rights="none" pattern="*" />
|
||||||
|
<policy domain="coder" rights="read|write" pattern="{GIF,JPEG,PNG,WEBP}" />
|
||||||
|
-->
|
||||||
|
<policymap>
|
||||||
|
<!-- <policy domain="system" name="shred" value="2"/> -->
|
||||||
|
<!-- <policy domain="system" name="precision" value="6"/> -->
|
||||||
|
<!-- <policy domain="system" name="memory-map" value="anonymous"/> -->
|
||||||
|
<!-- <policy domain="system" name="max-memory-request" value="256MiB"/> -->
|
||||||
|
<!-- <policy domain="resource" name="temporary-path" value="/tmp"/> -->
|
||||||
|
<policy domain="resource" name="memory" value="256MiB"/>
|
||||||
|
<policy domain="resource" name="map" value="512MiB"/>
|
||||||
|
<policy domain="resource" name="width" value="16KP"/>
|
||||||
|
<policy domain="resource" name="height" value="16KP"/>
|
||||||
|
<!-- <policy domain="resource" name="list-length" value="128"/> -->
|
||||||
|
<policy domain="resource" name="area" value="128MB"/>
|
||||||
|
<policy domain="resource" name="disk" value="1GiB"/>
|
||||||
|
<!-- <policy domain="resource" name="file" value="768"/> -->
|
||||||
|
<!-- <policy domain="resource" name="thread" value="4"/> -->
|
||||||
|
<!-- <policy domain="resource" name="throttle" value="0"/> -->
|
||||||
|
<!-- <policy domain="resource" name="time" value="3600"/> -->
|
||||||
|
<!-- Paperless does not process SVG or ImageMagick scripting formats. -->
|
||||||
|
<policy domain="coder" rights="none" pattern="SVG" />
|
||||||
|
<policy domain="coder" rights="none" pattern="SVGZ" />
|
||||||
|
<policy domain="coder" rights="none" pattern="MSVG" />
|
||||||
|
<policy domain="coder" rights="none" pattern="RSVG" />
|
||||||
|
<policy domain="coder" rights="none" pattern="MSL" />
|
||||||
|
<policy domain="coder" rights="none" pattern="MVG" />
|
||||||
|
<policy domain="coder" rights="none" pattern="EPHEMERAL" />
|
||||||
|
<!-- <policy domain="module" rights="none" pattern="{PS,PDF,XPS}" /> -->
|
||||||
|
<!-- <policy domain="delegate" rights="none" pattern="HTTPS" /> -->
|
||||||
|
<!-- <policy domain="path" rights="none" pattern="@*" /> -->
|
||||||
|
<!-- <policy domain="cache" name="memory-map" value="anonymous"/> -->
|
||||||
|
<!-- <policy domain="cache" name="synchronize" value="True"/> -->
|
||||||
|
<!-- <policy domain="cache" name="shared-secret" value="passphrase" stealth="true"/> -->
|
||||||
|
<!-- <policy domain="system" name="pixel-cache-memory" value="anonymous"/> -->
|
||||||
|
<!-- <policy domain="system" name="shred" value="2"/> -->
|
||||||
|
<!-- <policy domain="system" name="precision" value="6"/> -->
|
||||||
|
<!-- use curl -->
|
||||||
|
<policy domain="delegate" rights="none" pattern="URL" />
|
||||||
|
<policy domain="delegate" rights="none" pattern="HTTPS" />
|
||||||
|
<policy domain="delegate" rights="none" pattern="HTTP" />
|
||||||
|
<!-- in order to avoid to get image with password text -->
|
||||||
|
<policy domain="path" rights="none" pattern="@*"/>
|
||||||
|
<!-- disable ghostscript format types -->
|
||||||
|
<policy domain="coder" rights="none" pattern="PS" />
|
||||||
|
<policy domain="coder" rights="none" pattern="PS2" />
|
||||||
|
<policy domain="coder" rights="none" pattern="PS3" />
|
||||||
|
<policy domain="coder" rights="none" pattern="EPS" />
|
||||||
|
<policy domain="coder" rights="read|write" pattern="PDF" />
|
||||||
|
<policy domain="coder" rights="none" pattern="XPS" />
|
||||||
|
</policymap>
|
||||||
@@ -1,64 +1,5 @@
|
|||||||
# 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
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- Fix: copy only direct user grants in DocumentMetadataOverrides.from\_document [@stumpylog](https://github.com/stumpylog) ([#14452](https://github.com/paperless-ngx/paperless-ngx/pull/14452))
|
|
||||||
- Fix: support combined text/query searches [@shamoon](https://github.com/shamoon) ([#14443](https://github.com/paperless-ngx/paperless-ngx/pull/14443))
|
|
||||||
- Fix: make exporter derived file names unique when originals differ only by extension [@stumpylog](https://github.com/stumpylog) ([#14429](https://github.com/paperless-ngx/paperless-ngx/pull/14429))
|
|
||||||
|
|
||||||
### Dependencies
|
|
||||||
|
|
||||||
- Chore(deps): Bump filelock from 3.32.6 to 4.0.12 @[dependabot[bot]](https://github.com/apps/dependabot) ([#14336](https://github.com/paperless-ngx/paperless-ngx/pull/14336))
|
|
||||||
- Chore(deps): Bump the uv group across 1 directory with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14418](https://github.com/paperless-ngx/paperless-ngx/pull/14418))
|
|
||||||
|
|
||||||
### All App Changes
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>5 changes</summary>
|
|
||||||
|
|
||||||
- Fix: copy only direct user grants in DocumentMetadataOverrides.from\_document [@stumpylog](https://github.com/stumpylog) ([#14452](https://github.com/paperless-ngx/paperless-ngx/pull/14452))
|
|
||||||
- Chore(deps): Bump filelock from 3.32.6 to 4.0.12 @[dependabot[bot]](https://github.com/apps/dependabot) ([#14336](https://github.com/paperless-ngx/paperless-ngx/pull/14336))
|
|
||||||
- Fix: support combined text/query searches [@shamoon](https://github.com/shamoon) ([#14443](https://github.com/paperless-ngx/paperless-ngx/pull/14443))
|
|
||||||
- Chore(deps): Bump the uv group across 1 directory with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14418](https://github.com/paperless-ngx/paperless-ngx/pull/14418))
|
|
||||||
- Fix: make exporter derived file names unique when originals differ only by extension [@stumpylog](https://github.com/stumpylog) ([#14429](https://github.com/paperless-ngx/paperless-ngx/pull/14429))
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## paperless-ngx 3.3.1
|
## paperless-ngx 3.3.1
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
+20
-14
@@ -1315,17 +1315,29 @@ 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}
|
||||||
|
|
||||||
!!! warning
|
: On smaller systems, or even in the case of Very Large Documents, the
|
||||||
|
consumer may explode, complaining about how it's "unable to extend
|
||||||
|
pixel cache". In such cases, try setting this to a reasonably low
|
||||||
|
value, like 32. The default is to use whatever is necessary to do
|
||||||
|
everything without writing to disk, and units are in megabytes.
|
||||||
|
|
||||||
Deprecated and has no effect, since PDF thumbnails no longer use
|
For more information on how to use this value, you should search the
|
||||||
ImageMagick. It will be removed in a future release.
|
web for "MAGICK_MEMORY_LIMIT".
|
||||||
|
|
||||||
|
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}
|
||||||
|
|
||||||
!!! warning
|
: Similar to the memory limit, if you've got a small system and your
|
||||||
|
OS mounts /tmp as tmpfs, you should set this to a path that's on a
|
||||||
|
physical disk, like /home/your_user/tmp or something. ImageMagick
|
||||||
|
will use this as scratch space when crunching through very large
|
||||||
|
documents.
|
||||||
|
|
||||||
Deprecated and has no effect, since PDF thumbnails no longer use
|
For more information on how to use this value, you should search the
|
||||||
ImageMagick. It will be removed in a future release.
|
web for "MAGICK_TMPDIR".
|
||||||
|
|
||||||
|
Default is none, which disables the temporary directory.
|
||||||
|
|
||||||
#### [`PAPERLESS_APPS=<string>`](#PAPERLESS_APPS) {#PAPERLESS_APPS}
|
#### [`PAPERLESS_APPS=<string>`](#PAPERLESS_APPS) {#PAPERLESS_APPS}
|
||||||
|
|
||||||
@@ -1873,17 +1885,11 @@ 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}
|
||||||
|
|
||||||
!!! warning
|
: Defaults to "convert".
|
||||||
|
|
||||||
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}
|
||||||
|
|
||||||
!!! warning
|
: Defaults to "gs".
|
||||||
|
|
||||||
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}
|
||||||
|
|
||||||
|
|||||||
+13
-2
@@ -177,16 +177,17 @@ 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 thumbnail generation and barcode detection
|
- `poppler-utils` for barcode detection
|
||||||
|
|
||||||
Use this list for your preferred package management:
|
Use this list for your preferred package management:
|
||||||
|
|
||||||
```
|
```
|
||||||
python3 python3-pip python3-dev fonts-liberation gnupg libpq-dev default-libmysqlclient-dev pkg-config libmagic-dev poppler-utils
|
python3 python3-pip python3-dev imagemagick fonts-liberation gnupg libpq-dev default-libmysqlclient-dev pkg-config libmagic-dev poppler-utils
|
||||||
```
|
```
|
||||||
|
|
||||||
These dependencies are required for OCRmyPDF, which is used for text
|
These dependencies are required for OCRmyPDF, which is used for text
|
||||||
@@ -415,6 +416,16 @@ to a positive number to enable polling and disable native filesystem notificatio
|
|||||||
You may need to change the path in the files. Example:
|
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.
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ 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
|
||||||
|
|
||||||
@@ -85,3 +87,8 @@ 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
|
||||||
+4
-4
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "paperless-ngx"
|
name = "paperless-ngx"
|
||||||
version = "3.3.3"
|
version = "3.3.1"
|
||||||
description = """\
|
description = """\
|
||||||
A community-supported supercharged document management system: scan, index and archive all your physical documents\
|
A community-supported supercharged document management system: scan, index and archive all your physical documents\
|
||||||
"""
|
"""
|
||||||
@@ -43,7 +43,7 @@ dependencies = [
|
|||||||
"drf-spectacular~=0.30",
|
"drf-spectacular~=0.30",
|
||||||
"drf-spectacular-sidecar>=2026.7.1,<2026.10",
|
"drf-spectacular-sidecar>=2026.7.1,<2026.10",
|
||||||
"drf-writable-nested~=0.7.1",
|
"drf-writable-nested~=0.7.1",
|
||||||
"filelock>=3.32,<4.1",
|
"filelock~=3.32.0",
|
||||||
"flower>=2.0.1,<2.3",
|
"flower>=2.0.1,<2.3",
|
||||||
"gotenberg-client[httpx]~=1.0",
|
"gotenberg-client[httpx]~=1.0",
|
||||||
"httpx-oauth~=0.17",
|
"httpx-oauth~=0.17",
|
||||||
@@ -256,8 +256,8 @@ isort.force-single-line = true
|
|||||||
[tool.codespell]
|
[tool.codespell]
|
||||||
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish,NIN,nin,reprot"
|
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish,NIN,nin,reprot"
|
||||||
skip = """\
|
skip = """\
|
||||||
src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/paperless/tests/samples\
|
src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless/tests/samples/mail/*,src/documents/tests/samples\
|
||||||
/mail/*,src/documents/tests/samples/*,*.po,*.json\
|
/*,src/paperless_testing/sample_files/*,*.po,*.json\
|
||||||
"""
|
"""
|
||||||
write-changes = true
|
write-changes = true
|
||||||
|
|
||||||
|
|||||||
@@ -134,7 +134,9 @@ def seed_database() -> None:
|
|||||||
originals.mkdir(parents=True)
|
originals.mkdir(parents=True)
|
||||||
thumbnails = Path(os.environ["PAPERLESS_MEDIA_ROOT"]) / "documents" / "thumbnails"
|
thumbnails = Path(os.environ["PAPERLESS_MEDIA_ROOT"]) / "documents" / "thumbnails"
|
||||||
thumbnails.mkdir(parents=True)
|
thumbnails.mkdir(parents=True)
|
||||||
sample_pdf = SOURCE_ROOT / "documents" / "tests" / "samples" / "simple.pdf"
|
sample_pdf = (
|
||||||
|
SOURCE_ROOT / "paperless_testing" / "sample_files" / "simple-digital.pdf"
|
||||||
|
)
|
||||||
for document in documents:
|
for document in documents:
|
||||||
shutil.copyfile(sample_pdf, originals / document.filename)
|
shutil.copyfile(sample_pdf, originals / document.filename)
|
||||||
shutil.copyfile(
|
shutil.copyfile(
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ module.exports = {
|
|||||||
tsconfig: '<rootDir>/tsconfig.spec.json',
|
tsconfig: '<rootDir>/tsconfig.spec.json',
|
||||||
stringifyContentPathRegex: '\\.(html|svg)$',
|
stringifyContentPathRegex: '\\.(html|svg)$',
|
||||||
useESM: true,
|
useESM: true,
|
||||||
// Disable the builder's deprecated override; tsconfig.spec.json enables isolation.
|
|
||||||
isolatedModules: false,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -28,7 +26,7 @@ module.exports = {
|
|||||||
'abstract-paperless-service',
|
'abstract-paperless-service',
|
||||||
],
|
],
|
||||||
transformIgnorePatterns: [
|
transformIgnorePatterns: [
|
||||||
'node_modules/(?!.*(\\.mjs$|tslib|lodash-es|normalize-diacritics|marked|@angular/common/locales/.*\\.js$))',
|
'node_modules/(?!.*(\\.mjs$|tslib|lodash-es|normalize-diacritics|@angular/common/locales/.*\\.js$))',
|
||||||
],
|
],
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
...esmPreset.moduleNameMapper,
|
...esmPreset.moduleNameMapper,
|
||||||
|
|||||||
+709
-824
File diff suppressed because it is too large.
Load diff
+1
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paperless-ngx-ui",
|
"name": "paperless-ngx-ui",
|
||||||
"version": "3.3.3",
|
"version": "3.3.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"preinstall": "npx only-allow pnpm",
|
"preinstall": "npx only-allow pnpm",
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
@@ -30,7 +30,6 @@
|
|||||||
"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",
|
||||||
|
|||||||
Generated
-10
@@ -53,9 +53,6 @@ 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
|
||||||
@@ -3229,11 +3226,6 @@ 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==}
|
||||||
|
|
||||||
@@ -7417,8 +7409,6 @@ 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: {}
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ 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,
|
||||||
@@ -35,7 +34,6 @@ 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 () => {
|
||||||
@@ -62,7 +60,6 @@ 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)
|
||||||
@@ -120,33 +117,30 @@ describe('AppComponent', () => {
|
|||||||
expect(toast.action).toBeUndefined()
|
expect(toast.action).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should only reload tasks when a document is detected', () => {
|
it('should display toast on document added', () => {
|
||||||
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||||
const toastSpy = jest.spyOn(toastService, 'show')
|
const 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(reloadSpy).toHaveBeenCalled()
|
expect(toastSpy).toHaveBeenCalled()
|
||||||
expect(toastSpy).not.toHaveBeenCalled()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should add consumed documents to the bell without a popup', () => {
|
it('should suppress dashboard notifications if set', () => {
|
||||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||||
const popupSpy = jest.fn()
|
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
||||||
toastService.showToast.subscribe(popupSpy)
|
jest.spyOn(router, 'url', 'get').mockReturnValue('/dashboard')
|
||||||
let toasts: Toast[] = []
|
const toastSpy = jest.spyOn(toastService, 'show')
|
||||||
toastService.getToasts().subscribe((t) => (toasts = [...t]))
|
|
||||||
const fileStatusSubject = new Subject<FileStatus>()
|
const fileStatusSubject = new Subject<FileStatus>()
|
||||||
jest
|
jest
|
||||||
.spyOn(websocketStatusService, 'onDocumentConsumptionFinished')
|
.spyOn(websocketStatusService, 'onDocumentDetected')
|
||||||
.mockReturnValue(fileStatusSubject)
|
.mockReturnValue(fileStatusSubject)
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
fileStatusSubject.next(new FileStatus())
|
fileStatusSubject.next(new FileStatus())
|
||||||
expect(toasts).toHaveLength(1)
|
expect(toastSpy).not.toHaveBeenCalled()
|
||||||
expect(popupSpy).not.toHaveBeenCalled()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should display toast on document failed', () => {
|
it('should display toast on document failed', () => {
|
||||||
|
|||||||
@@ -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 { ToastLevel, ToastService } from './services/toast.service'
|
import { ToastService } from './services/toast.service'
|
||||||
import { WebsocketStatusService } from './services/websocket-status.service'
|
import { WebsocketStatusService } from './services/websocket-status.service'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -61,6 +61,18 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private showNotification(key) {
|
||||||
|
if (
|
||||||
|
this.router.url == '/dashboard' &&
|
||||||
|
this.settings.get(
|
||||||
|
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return this.settings.get(key)
|
||||||
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.websocketStatusService.connect()
|
this.websocketStatusService.connect()
|
||||||
|
|
||||||
@@ -68,22 +80,29 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
.onDocumentConsumptionFinished()
|
.onDocumentConsumptionFinished()
|
||||||
.subscribe((status) => {
|
.subscribe((status) => {
|
||||||
this.tasksService.reload()
|
this.tasksService.reload()
|
||||||
if (this.settings.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS)) {
|
if (
|
||||||
const canView = this.permissionsService.currentUserCan(
|
this.showNotification(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS)
|
||||||
PermissionAction.View,
|
) {
|
||||||
PermissionType.Document
|
if (
|
||||||
)
|
this.permissionsService.currentUserCan(
|
||||||
// the upload status tray already shows this, so only keep a record
|
PermissionAction.View,
|
||||||
this.toastService.show({
|
PermissionType.Document
|
||||||
content: $localize`Document ${status.filename} was added to Paperless-ngx.`,
|
)
|
||||||
delay: 10000,
|
) {
|
||||||
level: ToastLevel.Success,
|
this.toastService.show({
|
||||||
silent: true,
|
content: $localize`Document ${status.filename} was added to Paperless-ngx.`,
|
||||||
actionName: canView ? $localize`Open document` : undefined,
|
delay: 10000,
|
||||||
action: canView
|
actionName: $localize`Open document`,
|
||||||
? () => this.router.navigate(['documents', status.documentId])
|
action: () => {
|
||||||
: undefined,
|
this.router.navigate(['documents', status.documentId])
|
||||||
})
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
this.toastService.show({
|
||||||
|
content: $localize`Document ${status.filename} was added to Paperless-ngx.`,
|
||||||
|
delay: 10000,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -91,7 +110,9 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
.onDocumentConsumptionFailed()
|
.onDocumentConsumptionFailed()
|
||||||
.subscribe((status) => {
|
.subscribe((status) => {
|
||||||
this.tasksService.reload()
|
this.tasksService.reload()
|
||||||
if (this.settings.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED)) {
|
if (
|
||||||
|
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}`
|
||||||
)
|
)
|
||||||
@@ -100,8 +121,18 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
this.newDocumentSubscription = this.websocketStatusService
|
this.newDocumentSubscription = this.websocketStatusService
|
||||||
.onDocumentDetected()
|
.onDocumentDetected()
|
||||||
.subscribe(() => {
|
.subscribe((status) => {
|
||||||
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.showSuccess($localize`Configuration updated`)
|
this.toastService.showInfo($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.showSuccess($localize`File successfully updated`)
|
this.toastService.showInfo($localize`File successfully updated`)
|
||||||
},
|
},
|
||||||
error: (e) => {
|
error: (e) => {
|
||||||
this.loading.set(false)
|
this.loading.set(false)
|
||||||
|
|||||||
@@ -425,8 +425,10 @@
|
|||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<pngx-input-check i18n-title title="Add notifications when document processing completes successfully" formControlName="notificationsConsumerSuccess" i18n-hint hint="These are added to the notifications menu without a popup, processing progress is shown at the bottom of the screen."></pngx-input-check>
|
<pngx-input-check i18n-title title="Show notifications when new documents are detected" formControlName="notificationsConsumerNewDocument"></pngx-input-check>
|
||||||
|
<pngx-input-check i18n-title title="Show notifications when document processing completes successfully" formControlName="notificationsConsumerSuccess"></pngx-input-check>
|
||||||
<pngx-input-check i18n-title title="Show notifications when document processing fails" formControlName="notificationsConsumerFailed"></pngx-input-check>
|
<pngx-input-check i18n-title title="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(33)
|
expect(setSpy).toHaveBeenCalledTimes(35)
|
||||||
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, ToastLevel, ToastService } from 'src/app/services/toast.service'
|
import { Toast, ToastService } from 'src/app/services/toast.service'
|
||||||
import { locationReload } from 'src/app/utils/navigation'
|
import { locationReload } from 'src/app/utils/navigation'
|
||||||
import { CheckComponent } from '../../common/input/check/check.component'
|
import { CheckComponent } from '../../common/input/check/check.component'
|
||||||
import { ColorComponent } from '../../common/input/color/color.component'
|
import { ColorComponent } from '../../common/input/color/color.component'
|
||||||
@@ -190,8 +190,10 @@ 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),
|
||||||
@@ -369,12 +371,18 @@ 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
|
||||||
),
|
),
|
||||||
@@ -563,6 +571,10 @@ 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
|
||||||
@@ -571,6 +583,10 @@ 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
|
||||||
@@ -648,11 +664,8 @@ 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 = () => {
|
||||||
|
|||||||
@@ -102,34 +102,25 @@
|
|||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (selectedSection !== TaskSection.All && totalTasks() > pageSize) {
|
<ngb-pagination
|
||||||
<ngb-pagination
|
[pageSize]="pageSize"
|
||||||
[pageSize]="pageSize"
|
[collectionSize]="totalTasks()"
|
||||||
[collectionSize]="totalTasks()"
|
[page]="page"
|
||||||
[page]="page"
|
[maxSize]="5"
|
||||||
[maxSize]="5"
|
[rotate]="true"
|
||||||
[rotate]="true"
|
size="sm"
|
||||||
size="sm"
|
aria-label="Tasks pagination"
|
||||||
aria-label="Tasks pagination"
|
(pageChange)="setPage($event)">
|
||||||
(pageChange)="setPage($event)">
|
</ngb-pagination>
|
||||||
</ngb-pagination>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ng-template let-tasks="tasks" let-section="section" #tasksTemplate>
|
<ng-template let-tasks="tasks" let-section="section" #tasksTemplate>
|
||||||
<div class="section-header mb-2">
|
<div class="section-header d-flex align-items-center justify-content-between mb-2">
|
||||||
<h5 class="mb-0">{{ sectionLabel(section) }}</h5>
|
<div>
|
||||||
@let count = sectionCount(section);
|
<h5 class="mb-0">{{ sectionLabel(section) }}</h5>
|
||||||
@let hasMore = selectedSection === TaskSection.All && count > tasks.length;
|
<div class="small text-muted">
|
||||||
<div class="small text-muted">
|
<ng-container i18n>{tasks.length, plural, =1 {1 task} other {{{tasks.length}} tasks}}</ng-container>
|
||||||
@if (hasMore) {
|
</div>
|
||||||
<ng-container i18n>{{tasks.length}} of {{count}} tasks</ng-container>
|
|
||||||
<button class="btn btn-link show-all p-0 ms-2 align-baseline" type="button" (click)="setSection(section)">
|
|
||||||
<ng-container i18n>Show all</ng-container><i-bs class="ms-1" width="1em" height="1em" name="arrow-right"></i-bs>
|
|
||||||
</button>
|
|
||||||
} @else {
|
|
||||||
<ng-container i18n>{count, plural, =1 {1 task} other {{{count}} tasks}}</ng-container>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -178,9 +169,10 @@
|
|||||||
<div>{{ taskDisplayName(task) }}</div>
|
<div>{{ taskDisplayName(task) }}</div>
|
||||||
<div class="small text-muted">
|
<div class="small text-muted">
|
||||||
@if (taskShowsSeparateTypeLabel(task)) {
|
@if (taskShowsSeparateTypeLabel(task)) {
|
||||||
<span class="me-2">{{ task.task_type_display }}</span>
|
<span>{{ task.task_type_display }}</span>
|
||||||
|
<span class="mx-1">•</span>
|
||||||
}
|
}
|
||||||
<span class="text-nowrap"><i-bs class="me-1" width="1em" height="1em" name="box-arrow-in-right"></i-bs>{{ task.trigger_source_display }}</span>
|
<span>{{ task.trigger_source_display }}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="d-none d-lg-table-cell created-col">{{ task.date_created | customDate:'short' }}</td>
|
<td class="d-none d-lg-table-cell created-col">{{ task.date_created | customDate:'short' }}</td>
|
||||||
|
|||||||
@@ -72,7 +72,3 @@ pre {
|
|||||||
tbody tr:nth-last-child(2) td {
|
tbody tr:nth-last-child(2) td {
|
||||||
border-bottom: none !important;
|
border-bottom: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-header .show-all {
|
|
||||||
font-size: inherit;
|
|
||||||
}
|
|
||||||
@@ -223,6 +223,16 @@ describe('TasksComponent', () => {
|
|||||||
)
|
)
|
||||||
.flush(paginatedTasks)
|
.flush(paginatedTasks)
|
||||||
|
|
||||||
|
httpTestingController
|
||||||
|
.expectOne(
|
||||||
|
(req) =>
|
||||||
|
req.url === `${environment.apiBaseUrl}tasks/` &&
|
||||||
|
req.params.get('acknowledged') === 'false' &&
|
||||||
|
req.params.get('page_size') === '25' &&
|
||||||
|
req.params.get('page') === '1'
|
||||||
|
)
|
||||||
|
.flush(paginatedTasks)
|
||||||
|
|
||||||
httpTestingController
|
httpTestingController
|
||||||
.expectOne(
|
.expectOne(
|
||||||
(req) =>
|
(req) =>
|
||||||
@@ -231,30 +241,8 @@ describe('TasksComponent', () => {
|
|||||||
!req.params.has('status')
|
!req.params.has('status')
|
||||||
)
|
)
|
||||||
.flush(sectionCountResponse)
|
.flush(sectionCountResponse)
|
||||||
|
|
||||||
flushTaskLists(10)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// answers task list requests with the fixture tasks matching their status filter
|
|
||||||
function flushTaskLists(pageSize: number, count?: number) {
|
|
||||||
httpTestingController
|
|
||||||
.match(
|
|
||||||
(req) =>
|
|
||||||
req.url === `${environment.apiBaseUrl}tasks/` &&
|
|
||||||
req.params.get('page_size') === `${pageSize}`
|
|
||||||
)
|
|
||||||
.forEach((req) => {
|
|
||||||
const statuses = req.request.params.getAll('status')
|
|
||||||
const results = tasks.filter((task) => statuses.includes(task.status))
|
|
||||||
req.flush({ count: count ?? results.length, results })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectSection(section: TaskSection, count: number) {
|
|
||||||
component.setSection(section)
|
|
||||||
flushTaskLists(25, count)
|
|
||||||
}
|
|
||||||
|
|
||||||
it('should display task sections with counts', () => {
|
it('should display task sections with counts', () => {
|
||||||
expect(component.selectedSection).toBe(TaskSection.All)
|
expect(component.selectedSection).toBe(TaskSection.All)
|
||||||
expect(component.selectedTaskType).toBeNull()
|
expect(component.selectedTaskType).toBeNull()
|
||||||
@@ -350,7 +338,6 @@ describe('TasksComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should render pagination controls next to the task filter', () => {
|
it('should render pagination controls next to the task filter', () => {
|
||||||
selectSection(TaskSection.Completed, 30)
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|
||||||
const controls = fixture.debugElement.query(By.css('.task-controls'))
|
const controls = fixture.debugElement.query(By.css('.task-controls'))
|
||||||
@@ -361,51 +348,6 @@ describe('TasksComponent', () => {
|
|||||||
expect(pagination).not.toBeNull()
|
expect(pagination).not.toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should only paginate a section with more than one page of tasks', () => {
|
|
||||||
const pagination = () =>
|
|
||||||
fixture.debugElement.query(By.css('ngb-pagination'))
|
|
||||||
|
|
||||||
fixture.detectChanges()
|
|
||||||
expect(pagination()).toBeNull()
|
|
||||||
|
|
||||||
selectSection(TaskSection.Completed, 2)
|
|
||||||
fixture.detectChanges()
|
|
||||||
expect(pagination()).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should load the all tasks overview one section at a time', () => {
|
|
||||||
expect(component.tasksForSection(TaskSection.NeedsAttention)).toHaveLength(
|
|
||||||
2
|
|
||||||
)
|
|
||||||
expect(component.tasksForSection(TaskSection.InProgress)).toHaveLength(3)
|
|
||||||
expect(component.tasksForSection(TaskSection.Completed)).toHaveLength(2)
|
|
||||||
expect(component.totalTasks()).toBe(7)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should show section totals and link truncated sections to their full list', () => {
|
|
||||||
component.sectionCounts.update((counts) => ({
|
|
||||||
...counts,
|
|
||||||
[TaskSection.Completed]: 27,
|
|
||||||
}))
|
|
||||||
fixture.detectChanges()
|
|
||||||
|
|
||||||
const header = (label: string) =>
|
|
||||||
fixture.debugElement
|
|
||||||
.queryAll(By.css('.section-header'))
|
|
||||||
.find((h) => h.nativeElement.textContent.includes(label))
|
|
||||||
|
|
||||||
expect(header('Needs attention').nativeElement.textContent).toContain(
|
|
||||||
'2 tasks'
|
|
||||||
)
|
|
||||||
expect(header('Needs attention').query(By.css('button'))).toBeNull()
|
|
||||||
|
|
||||||
expect(header('Recently completed').nativeElement.textContent).toContain(
|
|
||||||
'2 of 27 tasks'
|
|
||||||
)
|
|
||||||
header('Recently completed').query(By.css('button')).nativeElement.click()
|
|
||||||
expect(component.selectedSection).toBe(TaskSection.Completed)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should apply the selected section to the server-side task query', () => {
|
it('should apply the selected section to the server-side task query', () => {
|
||||||
component.setSection(TaskSection.NeedsAttention)
|
component.setSection(TaskSection.NeedsAttention)
|
||||||
|
|
||||||
@@ -426,56 +368,55 @@ describe('TasksComponent', () => {
|
|||||||
it('should apply task type and trigger source filters to the server-side task query', () => {
|
it('should apply task type and trigger source filters to the server-side task query', () => {
|
||||||
component.setTaskType(PaperlessTaskType.SanityCheck)
|
component.setTaskType(PaperlessTaskType.SanityCheck)
|
||||||
|
|
||||||
expect(
|
httpTestingController
|
||||||
httpTestingController.match(
|
.expectOne(
|
||||||
(request) =>
|
(request) =>
|
||||||
request.url === `${environment.apiBaseUrl}tasks/` &&
|
request.url === `${environment.apiBaseUrl}tasks/` &&
|
||||||
request.params.get('page_size') === '10' &&
|
request.params.get('page_size') === '25' &&
|
||||||
request.params.get('task_type') === PaperlessTaskType.SanityCheck
|
request.params.get('task_type') === PaperlessTaskType.SanityCheck
|
||||||
)
|
)
|
||||||
).toHaveLength(3)
|
.flush({ count: 1, results: [tasks[6]] })
|
||||||
|
|
||||||
component.setTriggerSource(PaperlessTaskTriggerSource.System)
|
component.setTriggerSource(PaperlessTaskTriggerSource.System)
|
||||||
|
|
||||||
expect(
|
httpTestingController
|
||||||
httpTestingController.match(
|
.expectOne(
|
||||||
(request) =>
|
(request) =>
|
||||||
request.url === `${environment.apiBaseUrl}tasks/` &&
|
request.url === `${environment.apiBaseUrl}tasks/` &&
|
||||||
request.params.get('page_size') === '10' &&
|
request.params.get('page_size') === '25' &&
|
||||||
request.params.get('task_type') === PaperlessTaskType.SanityCheck &&
|
request.params.get('task_type') === PaperlessTaskType.SanityCheck &&
|
||||||
request.params.get('trigger_source') ===
|
request.params.get('trigger_source') ===
|
||||||
PaperlessTaskTriggerSource.System
|
PaperlessTaskTriggerSource.System
|
||||||
)
|
)
|
||||||
).toHaveLength(3)
|
.flush({ count: 1, results: [tasks[6]] })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should apply text filters to the server-side task query', () => {
|
it('should apply text filters to the server-side task query', () => {
|
||||||
component.filterText = 'invoice'
|
component.filterText = 'invoice'
|
||||||
jest.advanceTimersByTime(150)
|
jest.advanceTimersByTime(150)
|
||||||
|
|
||||||
expect(
|
httpTestingController
|
||||||
httpTestingController.match(
|
.expectOne(
|
||||||
(request) =>
|
(request) =>
|
||||||
request.url === `${environment.apiBaseUrl}tasks/` &&
|
request.url === `${environment.apiBaseUrl}tasks/` &&
|
||||||
request.params.get('page_size') === '10' &&
|
request.params.get('page_size') === '25' &&
|
||||||
request.params.get('name') === 'invoice'
|
request.params.get('name') === 'invoice'
|
||||||
)
|
)
|
||||||
).toHaveLength(3)
|
.flush({ count: 1, results: [tasks[0]] })
|
||||||
|
|
||||||
component.setFilterTarget(TaskFilterTargetID.Result)
|
component.setFilterTarget(TaskFilterTargetID.Result)
|
||||||
|
|
||||||
expect(
|
httpTestingController
|
||||||
httpTestingController.match(
|
.expectOne(
|
||||||
(request) =>
|
(request) =>
|
||||||
request.url === `${environment.apiBaseUrl}tasks/` &&
|
request.url === `${environment.apiBaseUrl}tasks/` &&
|
||||||
request.params.get('page_size') === '10' &&
|
request.params.get('page_size') === '25' &&
|
||||||
request.params.get('result') === 'invoice'
|
request.params.get('result') === 'invoice'
|
||||||
)
|
)
|
||||||
).toHaveLength(3)
|
.flush({ count: 0, results: [] })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should load a different task page when pagination changes', () => {
|
it('should load a different task page when pagination changes', () => {
|
||||||
selectSection(TaskSection.Completed, 30)
|
|
||||||
component.setPage(2)
|
component.setPage(2)
|
||||||
|
|
||||||
const pageTwoTasks = {
|
const pageTwoTasks = {
|
||||||
@@ -499,7 +440,6 @@ describe('TasksComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should not replace section counts with current-page counts', () => {
|
it('should not replace section counts with current-page counts', () => {
|
||||||
selectSection(TaskSection.Completed, 30)
|
|
||||||
component.setPage(2)
|
component.setPage(2)
|
||||||
|
|
||||||
httpTestingController
|
httpTestingController
|
||||||
@@ -515,21 +455,11 @@ describe('TasksComponent', () => {
|
|||||||
results: [tasks[0]],
|
results: [tasks[0]],
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(component.sectionCount(TaskSection.All)).toBe(7)
|
|
||||||
expect(component.sectionCount(TaskSection.NeedsAttention)).toBe(2)
|
expect(component.sectionCount(TaskSection.NeedsAttention)).toBe(2)
|
||||||
expect(component.sectionCount(TaskSection.InProgress)).toBe(3)
|
expect(component.sectionCount(TaskSection.InProgress)).toBe(3)
|
||||||
expect(component.sectionCount(TaskSection.Completed)).toBe(2)
|
expect(component.sectionCount(TaskSection.Completed)).toBe(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should stop loading when the overview fails to load', () => {
|
|
||||||
component.setSection(TaskSection.All)
|
|
||||||
httpTestingController
|
|
||||||
.match((req) => req.params.get('page_size') === '10')[0]
|
|
||||||
.flush('', { status: 500, statusText: 'Error' })
|
|
||||||
|
|
||||||
expect(component.loading()).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should expose stable task type options and disable empty ones', () => {
|
it('should expose stable task type options and disable empty ones', () => {
|
||||||
expect(component.taskTypeOptions.map((option) => option.value)).toContain(
|
expect(component.taskTypeOptions.map((option) => option.value)).toContain(
|
||||||
PaperlessTaskType.TrainClassifier
|
PaperlessTaskType.TrainClassifier
|
||||||
|
|||||||
@@ -15,9 +15,6 @@ import {
|
|||||||
distinctUntilChanged,
|
distinctUntilChanged,
|
||||||
filter,
|
filter,
|
||||||
first,
|
first,
|
||||||
forkJoin,
|
|
||||||
map,
|
|
||||||
Observable,
|
|
||||||
Subject,
|
Subject,
|
||||||
takeUntil,
|
takeUntil,
|
||||||
timer,
|
timer,
|
||||||
@@ -28,7 +25,6 @@ import {
|
|||||||
PaperlessTaskTriggerSource,
|
PaperlessTaskTriggerSource,
|
||||||
PaperlessTaskType,
|
PaperlessTaskType,
|
||||||
} from 'src/app/data/paperless-task'
|
} from 'src/app/data/paperless-task'
|
||||||
import { Results } from 'src/app/data/results'
|
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||||
import { TasksService } from 'src/app/services/tasks.service'
|
import { TasksService } from 'src/app/services/tasks.service'
|
||||||
@@ -173,7 +169,6 @@ export class TasksComponent
|
|||||||
public expandedTask: number
|
public expandedTask: number
|
||||||
public autoRefreshEnabled: boolean = true
|
public autoRefreshEnabled: boolean = true
|
||||||
public readonly pageSize = 25
|
public readonly pageSize = 25
|
||||||
public readonly overviewSize = 10
|
|
||||||
public page: number = 1
|
public page: number = 1
|
||||||
readonly totalTasks = signal(0)
|
readonly totalTasks = signal(0)
|
||||||
readonly sectionCounts = signal<Record<TaskSection, number>>({
|
readonly sectionCounts = signal<Record<TaskSection, number>>({
|
||||||
@@ -489,6 +484,13 @@ export class TasksComponent
|
|||||||
return this.sectionCounts()[section]
|
return this.sectionCounts()[section]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private setSectionCount(section: TaskSection, count: number) {
|
||||||
|
this.sectionCounts.update((counts) => ({
|
||||||
|
...counts,
|
||||||
|
[section]: count,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
sectionShowsResults(section: TaskSection): boolean {
|
sectionShowsResults(section: TaskSection): boolean {
|
||||||
return section !== TaskSection.InProgress
|
return section !== TaskSection.InProgress
|
||||||
}
|
}
|
||||||
@@ -735,12 +737,21 @@ export class TasksComponent
|
|||||||
this.reloadSectionCounts()
|
this.reloadSectionCounts()
|
||||||
|
|
||||||
this.loading.set(true)
|
this.loading.set(true)
|
||||||
this.listTasks()
|
this.tasksService
|
||||||
|
.list(
|
||||||
|
this.page,
|
||||||
|
this.pageSize,
|
||||||
|
this.getParamsForSection(this.selectedSection)
|
||||||
|
)
|
||||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (result) => {
|
next: (result) => {
|
||||||
this.pagedTasks.set(result.results)
|
this.pagedTasks.set(result.results)
|
||||||
this.totalTasks.set(result.count)
|
this.totalTasks.set(result.count)
|
||||||
|
this.setSectionCount(TaskSection.All, result.count)
|
||||||
|
if (this.selectedSection !== TaskSection.All) {
|
||||||
|
this.setSectionCount(this.selectedSection, result.count)
|
||||||
|
}
|
||||||
this.loading.set(false)
|
this.loading.set(false)
|
||||||
if (
|
if (
|
||||||
this.page > 1 &&
|
this.page > 1 &&
|
||||||
@@ -756,30 +767,4 @@ export class TasksComponent
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private listTasks(): Observable<Results<PaperlessTask>> {
|
|
||||||
if (this.selectedSection !== TaskSection.All) {
|
|
||||||
return this.tasksService.list(
|
|
||||||
this.page,
|
|
||||||
this.pageSize,
|
|
||||||
this.getParamsForSection(this.selectedSection)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// All is an overview of each section to avoid splitting sections across pages
|
|
||||||
return forkJoin(
|
|
||||||
this.sections.map((section) =>
|
|
||||||
this.tasksService.list(
|
|
||||||
1,
|
|
||||||
this.overviewSize,
|
|
||||||
this.getParamsForSection(section)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).pipe(
|
|
||||||
map((results) => ({
|
|
||||||
count: results.reduce((sum, result) => sum + result.count, 0),
|
|
||||||
results: results.flatMap((result) => result.results),
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -99,16 +99,11 @@ 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 { ToastLevel, ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
import { TrashService } from 'src/app/services/trash.service'
|
import { 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,11 +87,9 @@ 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.show({
|
this.toastService.showInfo(
|
||||||
content: $localize`Document "${document.title}" deleted`,
|
$localize`Document "${document.title}" deleted`
|
||||||
delay: 5000,
|
)
|
||||||
level: ToastLevel.Success,
|
|
||||||
})
|
|
||||||
modal.close()
|
modal.close()
|
||||||
this.reload()
|
this.reload()
|
||||||
},
|
},
|
||||||
@@ -124,11 +122,7 @@ export class TrashComponent
|
|||||||
.emptyTrash(documents ? Array.from(documents) : null)
|
.emptyTrash(documents ? Array.from(documents) : null)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.toastService.show({
|
this.toastService.showInfo($localize`Document(s) deleted`)
|
||||||
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()
|
||||||
@@ -150,7 +144,6 @@ 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])
|
||||||
@@ -172,11 +165,7 @@ export class TrashComponent
|
|||||||
.restoreDocuments(documents ? Array.from(documents) : null)
|
.restoreDocuments(documents ? Array.from(documents) : null)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.toastService.show({
|
this.toastService.showInfo($localize`Document(s) restored`)
|
||||||
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, 'showSuccess')
|
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
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, 'showSuccess')
|
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
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'))
|
||||||
@@ -154,31 +154,6 @@ describe('UsersAndGroupsComponent', () => {
|
|||||||
jest.useRealTimers()
|
jest.useRealTimers()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should logout current user under base href if password changed', () => {
|
|
||||||
jest.useFakeTimers()
|
|
||||||
const base = document.createElement('base')
|
|
||||||
base.href = '/paperless/'
|
|
||||||
document.head.appendChild(base)
|
|
||||||
completeSetup()
|
|
||||||
let modal: NgbModalRef
|
|
||||||
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
|
|
||||||
component.editUser(users[0])
|
|
||||||
const navSpy = jest
|
|
||||||
.spyOn(navUtils, 'setLocationHref')
|
|
||||||
.mockImplementation(() => {})
|
|
||||||
const editDialog = modal.componentInstance as UserEditDialogComponent
|
|
||||||
editDialog.passwordIsSet = true
|
|
||||||
settingsService.currentUser.set(users[0]) // simulate logged in as same user
|
|
||||||
editDialog.succeeded.emit(users[0])
|
|
||||||
fixture.detectChanges()
|
|
||||||
jest.advanceTimersByTime(2600)
|
|
||||||
expect(navSpy).toHaveBeenCalledWith(
|
|
||||||
`${window.location.origin}/paperless/accounts/logout/?next=/paperless/accounts/login/?next=/paperless/`
|
|
||||||
)
|
|
||||||
base.remove()
|
|
||||||
jest.useRealTimers()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should support edit / create group, show error if needed', () => {
|
it('should support edit / create group, show error if needed', () => {
|
||||||
completeSetup()
|
completeSetup()
|
||||||
let modal: NgbModalRef
|
let modal: NgbModalRef
|
||||||
@@ -186,7 +161,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, 'showSuccess')
|
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
editDialog.failed.emit()
|
editDialog.failed.emit()
|
||||||
expect(toastErrorSpy).toHaveBeenCalled()
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
editDialog.succeeded.emit(groups[0])
|
editDialog.succeeded.emit(groups[0])
|
||||||
@@ -204,7 +179,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, 'showSuccess')
|
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
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'))
|
||||||
|
|||||||
@@ -116,13 +116,12 @@ export class UsersAndGroupsComponent
|
|||||||
$localize`Password has been changed, you will be logged out momentarily.`
|
$localize`Password has been changed, you will be logged out momentarily.`
|
||||||
)
|
)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const basePath = new URL(document.baseURI).pathname
|
|
||||||
setLocationHref(
|
setLocationHref(
|
||||||
`${window.location.origin}${basePath}accounts/logout/?next=${basePath}accounts/login/?next=${basePath}`
|
`${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/`
|
||||||
)
|
)
|
||||||
}, 2500)
|
}, 2500)
|
||||||
} else {
|
} else {
|
||||||
this.toastService.showSuccess(
|
this.toastService.showInfo(
|
||||||
$localize`Saved user "${newUser.username}".`
|
$localize`Saved user "${newUser.username}".`
|
||||||
)
|
)
|
||||||
this.usersService.listAll().subscribe((r) => {
|
this.usersService.listAll().subscribe((r) => {
|
||||||
@@ -151,9 +150,7 @@ export class UsersAndGroupsComponent
|
|||||||
this.usersService.delete(user).subscribe({
|
this.usersService.delete(user).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
modal.close()
|
modal.close()
|
||||||
this.toastService.showSuccess(
|
this.toastService.showInfo($localize`Deleted user "${user.username}"`)
|
||||||
$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)
|
||||||
})
|
})
|
||||||
@@ -180,9 +177,7 @@ export class UsersAndGroupsComponent
|
|||||||
modal.componentInstance.succeeded
|
modal.componentInstance.succeeded
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe((newGroup) => {
|
.subscribe((newGroup) => {
|
||||||
this.toastService.showSuccess(
|
this.toastService.showInfo($localize`Saved group "${newGroup.name}".`)
|
||||||
$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)
|
||||||
})
|
})
|
||||||
@@ -208,9 +203,7 @@ export class UsersAndGroupsComponent
|
|||||||
this.groupsService.delete(group).subscribe({
|
this.groupsService.delete(group).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
modal.close()
|
modal.close()
|
||||||
this.toastService.showSuccess(
|
this.toastService.showInfo($localize`Deleted group "${group.name}"`)
|
||||||
$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,20 +29,8 @@
|
|||||||
</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 d-flex align-items-stretch gap-3">
|
<div class="col-12 header-search mx-auto">
|
||||||
<pngx-global-search class="flex-grow-1"></pngx-global-search>
|
<pngx-global-search></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">
|
||||||
@@ -82,9 +70,6 @@
|
|||||||
</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"
|
||||||
@@ -434,6 +419,5 @@
|
|||||||
[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>
|
||||||
@@ -66,7 +66,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.search-container {
|
.search-container {
|
||||||
min-width: 0;
|
|
||||||
padding-left: 1rem;
|
padding-left: 1rem;
|
||||||
padding-right: 1rem;
|
padding-right: 1rem;
|
||||||
}
|
}
|
||||||
@@ -500,11 +499,6 @@ main {
|
|||||||
|
|
||||||
// true center the search with equal flex widths
|
// true center the search with equal flex widths
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.search-container {
|
|
||||||
// Prevent input padding and the conditional search button from resizing
|
|
||||||
flex-basis: 55rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-brand-container,
|
.navbar-brand-container,
|
||||||
.navbar > ul {
|
.navbar > ul {
|
||||||
display: flex; // so the brand link stays as wide as its contents
|
display: flex; // so the brand link stays as wide as its contents
|
||||||
@@ -538,36 +532,7 @@ main {
|
|||||||
|
|
||||||
.header-search {
|
.header-search {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 50rem;
|
max-width: 55rem;
|
||||||
}
|
|
||||||
|
|
||||||
.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) {
|
||||||
@@ -659,7 +624,8 @@ main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-toggle::after {
|
.dropdown-toggle::after {
|
||||||
display: none;
|
margin-left: 0.4em;
|
||||||
|
vertical-align: 0.155em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar .dropdown-menu {
|
.navbar .dropdown-menu {
|
||||||
@@ -696,7 +662,3 @@ 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, By } from '@angular/platform-browser'
|
import { BrowserModule } 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,11 +33,8 @@ 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'
|
||||||
@@ -465,45 +462,6 @@ 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()
|
||||||
@@ -525,7 +483,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, 'showSuccess')
|
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
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[]
|
||||||
@@ -567,7 +525,6 @@ 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' },
|
||||||
@@ -578,8 +535,7 @@ describe('AppFrameComponent', () => {
|
|||||||
])
|
])
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
expect(toastErrorSpy).toHaveBeenCalledTimes(2)
|
expect(toastErrorSpy).toHaveBeenCalledTimes(2)
|
||||||
expect(toastSuccessSpy).toHaveBeenCalledTimes(1)
|
expect(toastInfoSpy).toHaveBeenCalledTimes(3)
|
||||||
expect(toastInfoSpy).toHaveBeenCalledTimes(2)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should call maybeRefreshDocumentCounts after saved views reload', () => {
|
it('should call maybeRefreshDocumentCounts after saved views reload', () => {
|
||||||
|
|||||||
@@ -6,14 +6,7 @@ import {
|
|||||||
moveItemInArray,
|
moveItemInArray,
|
||||||
} from '@angular/cdk/drag-drop'
|
} from '@angular/cdk/drag-drop'
|
||||||
import { NgClass } from '@angular/common'
|
import { NgClass } from '@angular/common'
|
||||||
import {
|
import { Component, HostListener, inject, OnInit, signal } from '@angular/core'
|
||||||
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 {
|
||||||
@@ -55,15 +48,12 @@ 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 { UploadStatusTrayComponent } from '../common/upload-status-tray/upload-status-tray.component'
|
import { SwitchComponent } from '../common/input/switch/switch.component'
|
||||||
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
import { 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'
|
||||||
@@ -94,7 +84,6 @@ const SCROLL_THRESHOLD = 16
|
|||||||
TourNgBootstrap,
|
TourNgBootstrap,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
SwitchComponent,
|
SwitchComponent,
|
||||||
UploadStatusTrayComponent,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppFrameComponent
|
export class AppFrameComponent
|
||||||
@@ -112,10 +101,6 @@ 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)
|
||||||
@@ -186,8 +171,6 @@ 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)
|
||||||
@@ -494,7 +477,7 @@ export class AppFrameComponent
|
|||||||
|
|
||||||
this.settingsService.updateSidebarViewsSort(sidebarViews).subscribe({
|
this.settingsService.updateSidebarViewsSort(sidebarViews).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.toastService.showSuccess($localize`Sidebar views updated`)
|
this.toastService.showInfo($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)
|
||||||
@@ -528,22 +511,6 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onFileSelected(event: Event) {
|
|
||||||
const input = event.target as HTMLInputElement
|
|
||||||
const files = Array.from(input.files ?? [])
|
|
||||||
input.value = ''
|
|
||||||
if (!files.length) return
|
|
||||||
this.uploadDocumentsService.uploadFiles(files)
|
|
||||||
}
|
|
||||||
|
|
||||||
get uploadsInProgress(): number {
|
|
||||||
return this.websocketStatusService.getConsumerStatusNotCompleted().length
|
|
||||||
}
|
|
||||||
|
|
||||||
showUploadStatus() {
|
|
||||||
this.uploadStatusTray()?.expand()
|
|
||||||
}
|
|
||||||
|
|
||||||
onLogout() {
|
onLogout() {
|
||||||
this.openDocumentsService.closeAll()
|
this.openDocumentsService.closeAll()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,17 +70,6 @@ form {
|
|||||||
max-height: 75vh;
|
max-height: 75vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown.show {
|
|
||||||
// Hide the search icon when the dropdown is shown
|
|
||||||
i-bs[name="search"] {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control {
|
|
||||||
padding-left: 0.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-item {
|
.dropdown-item {
|
||||||
&:has(button:focus) {
|
&:has(button:focus) {
|
||||||
background-color: var(--pngx-bg-darker);
|
background-color: var(--pngx-bg-darker);
|
||||||
|
|||||||
@@ -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, 'showSuccess')
|
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
|
|
||||||
// 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, 'showSuccess')
|
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
|
|
||||||
// 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.showSuccess($localize`Successfully updated object.`)
|
this.toastService.showInfo($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.showSuccess($localize`Successfully updated object.`)
|
this.toastService.showInfo($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)
|
||||||
|
|||||||
+15
-18
@@ -1,31 +1,28 @@
|
|||||||
|
|
||||||
<li ngbDropdown class="nav-item position-relative" (openChange)="onOpenChange($event)">
|
<li ngbDropdown class="nav-item position-relative" (openChange)="onOpenChange($event)">
|
||||||
@if (unread().length) {
|
@if (toasts().length) {
|
||||||
<span class="notification-count badge rounded-pill z-3 pe-none position-absolute" [class.bg-danger]="hasUnreadError()" [class.bg-secondary]="!hasUnreadError()">{{ unread().length }}</span>
|
<span class="notification-count badge rounded-pill z-3 pe-none bg-secondary position-absolute">{{ toasts().length }}</span>
|
||||||
}
|
}
|
||||||
<button class="btn navbar-action border-0" id="notificationsDropdown" ngbDropdownToggle aria-label="Notifications" i18n-aria-label>
|
<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-2" aria-labelledby="notificationsDropdown">
|
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="notificationsDropdown">
|
||||||
<div class="d-flex align-items-center px-1 mb-2">
|
<div class="btn-toolbar align-items-center" role="toolbar">
|
||||||
<h6 class="mb-0" i18n>Notifications</h6>
|
<h6 i18n>Notifications</h6>
|
||||||
@if (toasts().length) {
|
<div class="btn-group ms-auto">
|
||||||
<button class="btn btn-link btn-sm p-0 ms-auto text-body-secondary text-decoration-none clear-all"
|
<button class="btn btn-sm btn-outline-secondary mb-2 ms-auto"
|
||||||
(click)="toastService.clearToasts()">
|
(click)="toastService.clearToasts()"
|
||||||
<i-bs width="1em" height="1em" name="check2-all" class="me-1"></i-bs><ng-container i18n>Clear All</ng-container>
|
[disabled]="toasts().length === 0"
|
||||||
</button>
|
i18n>Clear All</button>
|
||||||
}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if (toasts().length === 0) {
|
@if (toasts().length === 0) {
|
||||||
<div class="text-center text-body-secondary small py-4">
|
<p class="text-center mb-0 small text-muted"><em i18n>No notifications</em></p>
|
||||||
<i-bs width="1.5em" height="1.5em" name="bell" class="d-block mx-auto mb-2 opacity-50"></i-bs>
|
|
||||||
<ng-container i18n>No notifications</ng-container>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
<div class="scroll-list d-flex flex-column gap-2">
|
<div class="scroll-list">
|
||||||
@for (toast of toasts(); track toast.id) {
|
@for (toast of toasts(); track toast.id) {
|
||||||
<pngx-toast [autohide]="false" [toast]="toast" [now]="now()" [isNew]="newIds().has(toast.id)" (closed)="toastService.closeToast(toast)" (actioned)="onActioned(toast)"></pngx-toast>
|
<pngx-toast [autohide]="false" [toast]="toast" (hidden)="onHidden(toast)" (closed)="toastService.closeToast(toast)"></pngx-toast>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -30,7 +30,3 @@
|
|||||||
right: -3rem;
|
right: -3rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.clear-all:hover {
|
|
||||||
color: var(--bs-body-color) !important;
|
|
||||||
}
|
|
||||||
+1
-70
@@ -3,7 +3,7 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { 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, ToastLevel, ToastService } from 'src/app/services/toast.service'
|
import { Toast, ToastService } from 'src/app/services/toast.service'
|
||||||
import { ToastsDropdownComponent } from './toasts-dropdown.component'
|
import { ToastsDropdownComponent } from './toasts-dropdown.component'
|
||||||
|
|
||||||
const toasts = [
|
const toasts = [
|
||||||
@@ -90,73 +90,4 @@ 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,20 +1,12 @@
|
|||||||
|
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 { Subscription, interval, map } from 'rxjs'
|
import { map } from 'rxjs'
|
||||||
import { Toast, ToastLevel, ToastService } from 'src/app/services/toast.service'
|
import { Toast, ToastService } from 'src/app/services/toast.service'
|
||||||
import { ToastComponent } from '../../common/toast/toast.component'
|
import { ToastComponent } from '../../common/toast/toast.component'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -30,44 +22,13 @@ 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,85 +1,49 @@
|
|||||||
|
|
||||||
<li ngbDropdown class="nav-item" display="dynamic" placement="bottom-end" [popperOptions]="popperOptions" (openChange)="onOpenChange($event)">
|
<li ngbDropdown class="nav-item" (openChange)="onOpenChange($event)">
|
||||||
<button class="btn navbar-action border-0" id="chatDropdown" ngbDropdownToggle aria-label="Chat" i18n-aria-label>
|
<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-0 overflow-hidden" aria-labelledby="chatDropdown">
|
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown">
|
||||||
<div class="d-flex align-items-center border-bottom px-3 py-2">
|
<div class="chat-container bg-light p-2">
|
||||||
<i-bs width="1em" height="1em" name="stars" class="text-primary me-2"></i-bs>
|
<div class="chat-messages font-monospace small">
|
||||||
<span class="fw-semibold">
|
@for (message of messages(); track message) {
|
||||||
@if (documentId()) {
|
<div class="message d-flex flex-row small" [class.justify-content-end]="message.role === 'user'">
|
||||||
<ng-container i18n>Ask about this document</ng-container>
|
<div class="p-2 m-2" [class.bg-body]="message.role === 'user'">
|
||||||
} @else {
|
<span class="text-break">
|
||||||
<ng-container i18n>Ask about your documents</ng-container>
|
{{ message.content }}
|
||||||
}
|
@if (message.isStreaming) { <span class="blinking-cursor">|</span> }
|
||||||
</span>
|
</span>
|
||||||
@if (messages().length) {
|
@if (message.role === 'assistant' && message.references?.length) {
|
||||||
<button class="btn btn-sm btn-link text-muted ms-auto p-0" type="button" (click)="clearChat()" title="Clear chat" i18n-title>
|
<div class="chat-references list-group mt-3">
|
||||||
<i-bs width="1em" height="1em" name="arrow-counterclockwise"></i-bs>
|
@for (reference of message.references; track reference.id) {
|
||||||
</button>
|
<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>
|
||||||
</div>
|
</a>
|
||||||
|
}
|
||||||
<div class="chat-messages px-2">
|
</div>
|
||||||
@if (!messages().length) {
|
}
|
||||||
<p class="text-muted text-center py-4 px-3 mb-0" i18n>Answers come from your documents, with links to their sources.</p>
|
</div>
|
||||||
}
|
|
||||||
@for (message of messages(); track message) {
|
|
||||||
<div class="message d-flex flex-row" [class.justify-content-end]="message.role === 'user'" [class.mt-3]="message.role === 'user' && !$first">
|
|
||||||
<div class="p-2 m-2" [class.bg-body-secondary]="message.role === 'user'" [class.rounded-3]="message.role === 'user'" [class.flex-grow-1]="message.role === 'assistant'">
|
|
||||||
@if (message.isStreaming && !message.content) {
|
|
||||||
<div class="d-flex align-items-center text-muted">
|
|
||||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
|
||||||
@if (slowResponse()) {
|
|
||||||
<ng-container i18n>Still thinking...</ng-container>
|
|
||||||
} @else {
|
|
||||||
<ng-container i18n>Thinking...</ng-container>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
} @else if (message.role === 'assistant') {
|
|
||||||
<div class="chat-markdown text-break text-wrap" [class.streaming]="message.isStreaming" [innerHTML]="message.content | markdown"></div>
|
|
||||||
} @else {
|
|
||||||
<span class="text-break">{{ message.content }}</span>
|
|
||||||
}
|
|
||||||
@if (message.role === 'assistant' && !message.isStreaming && message.references?.length) {
|
|
||||||
<div class="chat-references list-group mt-3">
|
|
||||||
<div class="list-group-item text-muted small py-1" i18n>Sources</div>
|
|
||||||
@for (reference of message.references; track reference.id) {
|
|
||||||
<a class="list-group-item list-group-item-action d-flex align-items-center text-primary" [routerLink]="['/documents', reference.id]" [title]="reference.title">
|
|
||||||
<i-bs width="0.9em" height="0.9em" name="file-text" class="me-2 flex-shrink-0"></i-bs><span class="text-truncate">{{ reference.title }}</span>
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
}
|
<div #scrollAnchor></div>
|
||||||
<div #scrollAnchor></div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<form class="chat-input border-top p-2">
|
<form class="chat-input">
|
||||||
<label class="visually-hidden" for="chatInput" i18n>Message</label>
|
<label class="visually-hidden" for="chatInput" i18n>Message</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input
|
<input
|
||||||
#chatInput
|
#chatInput
|
||||||
id="chatInput"
|
id="chatInput"
|
||||||
class="form-control" name="chatInput" type="text"
|
class="form-control form-control-sm" 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)"
|
||||||
/>
|
/>
|
||||||
@if (loading()) {
|
<button class="btn btn-sm btn-secondary" type="button" (click)="sendMessage()" [disabled]="loading()">Send</button>
|
||||||
<button class="btn btn-outline-secondary" type="button" (click)="stopStreaming()" title="Stop" i18n-title aria-label="Stop" i18n-aria-label>
|
</div>
|
||||||
<i-bs width="1em" height="1em" name="x-lg"></i-bs>
|
</form>
|
||||||
</button>
|
</div>
|
||||||
} @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,13 +1,17 @@
|
|||||||
.dropdown-menu {
|
.dropdown-menu {
|
||||||
width: min(36rem, calc(100vw - 20px));
|
width: var(--pngx-toast-max-width);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-messages {
|
.chat-messages {
|
||||||
max-height: min(60dvh, 36rem);
|
max-height: 350px;
|
||||||
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;
|
||||||
}
|
}
|
||||||
@@ -16,44 +20,23 @@
|
|||||||
white-space: initial;
|
white-space: initial;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 575.98px) {
|
@media screen and (max-width: 400px) {
|
||||||
.chat-messages {
|
:host ::ng-deep .dropdown-menu-end {
|
||||||
max-height: calc(100dvh - 12rem);
|
right: -3rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-markdown.streaming ::ng-deep {
|
.blinking-cursor {
|
||||||
> :last-child:not(ul, ol, table)::after,
|
font-weight: bold;
|
||||||
> :is(ul, ol):last-child > li:last-child::after {
|
font-size: 1.2em;
|
||||||
content: '';
|
animation: blink 1s step-end infinite;
|
||||||
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 {
|
||||||
50% {
|
from, to {
|
||||||
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,16 +80,8 @@ 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()
|
||||||
@@ -104,8 +96,6 @@ 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
|
||||||
)
|
)
|
||||||
@@ -133,10 +123,6 @@ 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()
|
||||||
|
|
||||||
@@ -170,96 +156,13 @@ 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).toBe(
|
expect(component.messages()[1].content).toContain(
|
||||||
'Partial\n\n⚠️ Error receiving response.'
|
'⚠️ 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
|
||||||
@@ -304,33 +207,4 @@ 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,16 +10,12 @@ 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, Subscription } from 'rxjs'
|
import { filter, map } 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',
|
||||||
@@ -29,7 +25,6 @@ const SLOW_RESPONSE_DELAY_MS = 8000
|
|||||||
RouterModule,
|
RouterModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
MarkdownPipe,
|
|
||||||
],
|
],
|
||||||
templateUrl: './chat.component.html',
|
templateUrl: './chat.component.html',
|
||||||
styleUrl: './chat.component.scss',
|
styleUrl: './chat.component.scss',
|
||||||
@@ -39,8 +34,6 @@ 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)
|
||||||
@@ -50,11 +43,6 @@ 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()
|
||||||
@@ -93,96 +81,47 @@ 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.streamSubscription = this.chatService
|
this.chatService.streamChat(this.documentId(), this.input()).subscribe({
|
||||||
.streamChat(this.documentId(), this.input())
|
next: (chunk) => {
|
||||||
.subscribe({
|
const nextResponse = parseChatResponse(chunk)
|
||||||
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: () => {
|
||||||
this.flushTypewriter(assistantMessage)
|
assistantMessage.content += '\n\n⚠️ Error receiving response.'
|
||||||
assistantMessage.content += '\n\n⚠️ Error receiving response.'
|
assistantMessage.isStreaming = false
|
||||||
this.finishStreaming(assistantMessage)
|
this.notifyMessagesChanged()
|
||||||
},
|
this.loading.set(false)
|
||||||
complete: () => {
|
},
|
||||||
// let the typewriter catch up before hiding the cursor
|
complete: () => {
|
||||||
this.streamFinished = true
|
assistantMessage.isStreaming = false
|
||||||
if (!this.typewriterActive) this.finishStreaming(assistantMessage)
|
this.notifyMessagesChanged()
|
||||||
},
|
this.loading.set(false)
|
||||||
})
|
this.scrollToBottom()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
this.input.set('')
|
this.input.set('')
|
||||||
}
|
}
|
||||||
|
|
||||||
stopStreaming(): void {
|
private resetTypewriter(message: ChatMessage, content: string): 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()
|
||||||
@@ -202,7 +141,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,8 +149,7 @@ export class ChatComponent implements OnInit {
|
|||||||
this.notifyMessagesChanged()
|
this.notifyMessagesChanged()
|
||||||
this.scrollToBottom()
|
this.scrollToBottom()
|
||||||
|
|
||||||
// 10ms per character
|
setTimeout(() => this.playTypewriter(message), 10) // 10ms per character
|
||||||
this.typewriterTimeout = setTimeout(() => this.playTypewriter(message), 10)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private notifyMessagesChanged(): void {
|
private notifyMessagesChanged(): void {
|
||||||
|
|||||||
+1
-1
@@ -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, 'showSuccess')
|
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
const getFieldsSpy = jest.spyOn(
|
const getFieldsSpy = jest.spyOn(
|
||||||
CustomFieldsDropdownComponent.prototype as any,
|
CustomFieldsDropdownComponent.prototype as any,
|
||||||
'getFields'
|
'getFields'
|
||||||
|
|||||||
+1
-3
@@ -129,9 +129,7 @@ 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.showSuccess(
|
this.toastService.showInfo($localize`Saved field "${newField.name}".`)
|
||||||
$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 ps-1 pe-2">
|
<div class="input-group input-group-sm small 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 ps-1 pe-2">
|
<div class="input-group input-group-sm small 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"
|
||||||
|
|||||||
+1
-1
@@ -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, 'showSuccess')
|
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
deactivateSpy.mockReturnValueOnce(throwError(() => new Error('error')))
|
deactivateSpy.mockReturnValueOnce(throwError(() => new Error('error')))
|
||||||
component.deactivateTotp()
|
component.deactivateTotp()
|
||||||
expect(deactivateSpy).toHaveBeenCalled()
|
expect(deactivateSpy).toHaveBeenCalled()
|
||||||
|
|||||||
+1
-1
@@ -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.showSuccess($localize`Totp deactivated`)
|
this.toastService.showInfo($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`)
|
||||||
|
|||||||
+1
-2
@@ -209,7 +209,6 @@
|
|||||||
@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>
|
||||||
@@ -321,7 +320,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" [preserveHierarchy]="false" title="" formControlName="remove_tags"></pngx-input-tags>
|
<pngx-input-tags [allowCreate]="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>
|
||||||
|
|||||||
+2
-2
@@ -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, 'showSuccess')
|
const toastSuccessSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
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, 'showSuccess')
|
const toastSuccessSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
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'
|
||||||
|
|||||||
+1
-1
@@ -50,7 +50,7 @@ export class EmailDocumentDialogComponent extends LoadingComponentWithPermission
|
|||||||
this.emailSubject = ''
|
this.emailSubject = ''
|
||||||
this.emailMessage = ''
|
this.emailMessage = ''
|
||||||
this.close()
|
this.close()
|
||||||
this.toastService.showSuccess($localize`Email sent`)
|
this.toastService.showInfo($localize`Email sent`)
|
||||||
},
|
},
|
||||||
error: (e) => {
|
error: (e) => {
|
||||||
this.loading.set(false)
|
this.loading.set(false)
|
||||||
|
|||||||
+3
-3
@@ -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">
|
||||||
<span class="ms-2"><ng-container i18n>Create</ng-container> "{{filterText}}"</span>
|
<small class="ms-2"><ng-container i18n>Create</ng-container> "{{filterText}}"</small>
|
||||||
<i-bs width="1.5em" height="1em" name="plus"></i-bs>
|
<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">
|
||||||
<span class="ms-2" [ngClass]="{'fw-bold': modelIsDirty()}" i18n>Apply</span>
|
<small class="ms-2" [ngClass]="{'fw-bold': modelIsDirty()}" i18n>Apply</small>
|
||||||
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
|
<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">
|
||||||
<span class="ms-2 fw-bold">{{extraButtonTitle}}</span>
|
<small class="ms-2 fw-bold">{{extraButtonTitle}}</small>
|
||||||
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
|
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -29,7 +29,7 @@
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
|
||||||
small {
|
small {
|
||||||
font-size: 0.65rem;
|
font-size: 65%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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 {
|
||||||
{{item.name}}
|
<small>{{item.name}}</small>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
-5
@@ -23,9 +23,4 @@
|
|||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
pngx-tag {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
}
|
||||||
+1
-3
@@ -1,7 +1,5 @@
|
|||||||
@if (!accordion) {
|
@if (!accordion) {
|
||||||
@if (title?.length > 0) {
|
<h5 i18n>Permissions</h5>
|
||||||
<h5>{{title}}</h5>
|
|
||||||
}
|
|
||||||
<ng-container [ngTemplateOutlet]="permissionsForm"></ng-container>
|
<ng-container [ngTemplateOutlet]="permissionsForm"></ng-container>
|
||||||
}
|
}
|
||||||
@if (accordion) {
|
@if (accordion) {
|
||||||
|
|||||||
-3
@@ -59,9 +59,6 @@ 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,22 +222,6 @@ 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,9 +109,6 @@ 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[]>()
|
||||||
|
|
||||||
@@ -151,9 +148,7 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
|
|||||||
oldValue.splice(index, 1)
|
oldValue.splice(index, 1)
|
||||||
|
|
||||||
// remove children
|
// remove children
|
||||||
if (this.preserveHierarchy) {
|
oldValue = this.removeChildren(oldValue, tag)
|
||||||
oldValue = this.removeChildren(oldValue, tag)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.value = [...oldValue]
|
this.value = [...oldValue]
|
||||||
this.onChange(this.value)
|
this.onChange(this.value)
|
||||||
@@ -172,7 +167,7 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onAdd(tag: Tag) {
|
public onAdd(tag: Tag) {
|
||||||
if (this.preserveHierarchy && tag?.parent) {
|
if (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()) {
|
||||||
<button type="button" class="badge border-0 bg-primary text-primary-text-contrast ms-3 small fs-normal" (click)="copyID()" title="Copy ID" i18n-title>
|
<span class="badge bg-primary text-primary-text-contrast ms-3 small fs-normal cursor-pointer" (click)="copyID()">
|
||||||
@if (copied()) {
|
@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()}}
|
||||||
}
|
}
|
||||||
</button>
|
</span>
|
||||||
}
|
}
|
||||||
@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,13 +43,6 @@ 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,10 +27,7 @@ export class PageHeaderComponent {
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const title = this.title()
|
this.titleService.setTitle(`${this.title()} - ${environment.appTitle}`)
|
||||||
this.titleService.setTitle(
|
|
||||||
title ? `${title} - ${environment.appTitle}` : environment.appTitle
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-7
@@ -12,7 +12,7 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="me-1">
|
<div class="me-1">
|
||||||
<span i18n>All</span>
|
<small i18n>All</small>
|
||||||
</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">
|
||||||
{{ownerFilterLabel}}
|
<small>{{ownerFilterLabel}}</small>
|
||||||
</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">
|
||||||
{{ownerExclusionFilterLabel}}
|
<small>{{ownerExclusionFilterLabel}}</small>
|
||||||
</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">
|
||||||
{{sharedByFilterLabel}}
|
<small>{{sharedByFilterLabel}}</small>
|
||||||
</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">
|
||||||
<span i18n>Unowned</span>
|
<small i18n>Unowned</small>
|
||||||
</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"
|
class="user-select small"
|
||||||
[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"><ng-container i18n>Hide unowned</ng-container></label>
|
<label class="form-check-label w-100" for="hideUnowned"><small i18n>Hide unowned</small></label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-35
@@ -100,7 +100,7 @@ describe('ProfileEditDialogComponent', () => {
|
|||||||
expect(errorSpy).toHaveBeenCalled()
|
expect(errorSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
updateSpy.mockClear()
|
updateSpy.mockClear()
|
||||||
const infoSpy = jest.spyOn(toastService, 'showSuccess')
|
const infoSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
component.form.patchValue(newProfile)
|
component.form.patchValue(newProfile)
|
||||||
updateSpy.mockReturnValueOnce(of(newProfile))
|
updateSpy.mockReturnValueOnce(of(newProfile))
|
||||||
component.save()
|
component.save()
|
||||||
@@ -218,38 +218,6 @@ describe('ProfileEditDialogComponent', () => {
|
|||||||
jest.useRealTimers()
|
jest.useRealTimers()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should logout under base href on save if password changed', () => {
|
|
||||||
jest.useFakeTimers()
|
|
||||||
const base = document.createElement('base')
|
|
||||||
base.href = '/paperless/'
|
|
||||||
document.head.appendChild(base)
|
|
||||||
const getSpy = jest.spyOn(profileService, 'get')
|
|
||||||
getSpy.mockReturnValue(of(profile))
|
|
||||||
const getProvidersSpy = jest.spyOn(
|
|
||||||
profileService,
|
|
||||||
'getSocialAccountProviders'
|
|
||||||
)
|
|
||||||
getProvidersSpy.mockReturnValue(of(socialAccountProviders))
|
|
||||||
component.ngOnInit()
|
|
||||||
component['newPassword'] = 'new*pass'
|
|
||||||
component.form.get('password').patchValue('new*pass')
|
|
||||||
component.form.get('password_confirm').patchValue('new*pass')
|
|
||||||
|
|
||||||
const updateSpy = jest.spyOn(profileService, 'update')
|
|
||||||
updateSpy.mockReturnValue(of(null))
|
|
||||||
const navSpy = jest
|
|
||||||
.spyOn(navUtils, 'setLocationHref')
|
|
||||||
.mockImplementation(() => {})
|
|
||||||
component.save()
|
|
||||||
expect(updateSpy).toHaveBeenCalled()
|
|
||||||
jest.advanceTimersByTime(2600)
|
|
||||||
expect(navSpy).toHaveBeenCalledWith(
|
|
||||||
`${window.location.origin}/paperless/accounts/logout/?next=/paperless/accounts/login/?next=/paperless/`
|
|
||||||
)
|
|
||||||
base.remove()
|
|
||||||
jest.useRealTimers()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should support auth token copy', () => {
|
it('should support auth token copy', () => {
|
||||||
jest.useFakeTimers()
|
jest.useFakeTimers()
|
||||||
const getSpy = jest.spyOn(profileService, 'get')
|
const getSpy = jest.spyOn(profileService, 'get')
|
||||||
@@ -373,7 +341,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, 'showSuccess')
|
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
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 +373,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, 'showSuccess')
|
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
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()
|
||||||
|
|||||||
+4
-9
@@ -204,15 +204,14 @@ export class ProfileEditDialogComponent
|
|||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.toastService.showSuccess($localize`Profile updated successfully`)
|
this.toastService.showInfo($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.`
|
||||||
)
|
)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const basePath = new URL(document.baseURI).pathname
|
|
||||||
setLocationHref(
|
setLocationHref(
|
||||||
`${window.location.origin}${basePath}accounts/logout/?next=${basePath}accounts/login/?next=${basePath}`
|
`${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/`
|
||||||
)
|
)
|
||||||
}, 2500)
|
}, 2500)
|
||||||
}
|
}
|
||||||
@@ -307,9 +306,7 @@ 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.showSuccess(
|
this.toastService.showInfo($localize`TOTP activated successfully`)
|
||||||
$localize`TOTP activated successfully`
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
this.toastService.showError($localize`Error activating TOTP`)
|
this.toastService.showError($localize`Error activating TOTP`)
|
||||||
}
|
}
|
||||||
@@ -333,9 +330,7 @@ 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.showSuccess(
|
this.toastService.showInfo($localize`TOTP deactivated successfully`)
|
||||||
$localize`TOTP deactivated successfully`
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
this.toastService.showError($localize`Error deactivating TOTP`)
|
this.toastService.showError($localize`Error deactivating TOTP`)
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-33
@@ -1,6 +1,6 @@
|
|||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="clickSuggest()" [disabled]="disabled() || loading()" [aria-label]="noSuggestions ? 'No suggestions' : 'Suggest'" i18n-aria-label>
|
<button type="button" class="btn btn-sm btn-outline-primary" (click)="clickSuggest()" [disabled]="disabled() || loading() || (suggestions() && !aiEnabled())" [aria-label]="noSuggestions ? 'No suggestions' : 'Suggest'" i18n-aria-label>
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||||
} @else if (noSuggestions) {
|
} @else if (noSuggestions) {
|
||||||
@@ -18,44 +18,46 @@
|
|||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="btn-group" ngbDropdown #dropdown="ngbDropdown" [popperOptions]="popperOptions">
|
@if (aiEnabled()) {
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary" ngbDropdownToggle [disabled]="disabled() || loading() || !suggestions()" aria-expanded="false" aria-controls="suggestionsDropdown" aria-label="Suggestions dropdown">
|
<div class="btn-group" ngbDropdown #dropdown="ngbDropdown" [popperOptions]="popperOptions">
|
||||||
<span class="visually-hidden" i18n>Show suggestions</span>
|
<button type="button" class="btn btn-sm btn-outline-primary" ngbDropdownToggle [disabled]="disabled() || loading() || !suggestions()" aria-expanded="false" aria-controls="suggestionsDropdown" aria-label="Suggestions dropdown">
|
||||||
</button>
|
<span class="visually-hidden" i18n>Show suggestions</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
|
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
|
||||||
<div class="list-group list-group-flush small pb-0">
|
<div class="list-group list-group-flush small pb-0">
|
||||||
@if (novelSuggestions === 0 && fieldSuggestions === 0) {
|
@if (novelSuggestions === 0 && fieldSuggestions === 0) {
|
||||||
<div class="list-group-item text-muted fst-italic">
|
<div class="list-group-item text-muted fst-italic">
|
||||||
<small class="text-muted small fst-italic" i18n>No novel suggestions</small>
|
<small class="text-muted small fst-italic" i18n>No novel suggestions</small>
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
@if (suggestions()?.suggested_tags?.length > 0) {
|
|
||||||
<small class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="tags"></i-bs><ng-container i18n>Tags</ng-container></small>
|
|
||||||
@for (tag of suggestions().suggested_tags; track tag) {
|
|
||||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addTag.emit(tag)">{{ tag }}</button>
|
|
||||||
}
|
}
|
||||||
}
|
@if (suggestions()?.suggested_tags?.length > 0) {
|
||||||
@if (suggestions()?.suggested_document_types?.length > 0) {
|
<small class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="tags"></i-bs><ng-container i18n>Tags</ng-container></small>
|
||||||
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="hash"></i-bs><ng-container i18n>Document Types</ng-container></div>
|
@for (tag of suggestions().suggested_tags; track tag) {
|
||||||
@for (type of suggestions().suggested_document_types; track type) {
|
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addTag.emit(tag)">{{ tag }}</button>
|
||||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addDocumentType.emit(type)">{{ type }}</button>
|
}
|
||||||
}
|
}
|
||||||
}
|
@if (suggestions()?.suggested_document_types?.length > 0) {
|
||||||
@if (suggestions()?.suggested_correspondents?.length > 0) {
|
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="hash"></i-bs><ng-container i18n>Document Types</ng-container></div>
|
||||||
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="person"></i-bs><ng-container i18n>Correspondents</ng-container></div>
|
@for (type of suggestions().suggested_document_types; track type) {
|
||||||
@for (correspondent of suggestions().suggested_correspondents; track correspondent) {
|
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addDocumentType.emit(type)">{{ type }}</button>
|
||||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addCorrespondent.emit(correspondent)">{{ correspondent }}</button>
|
}
|
||||||
}
|
}
|
||||||
}
|
@if (suggestions()?.suggested_correspondents?.length > 0) {
|
||||||
@if (fieldSuggestions > 0) {
|
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="person"></i-bs><ng-container i18n>Correspondents</ng-container></div>
|
||||||
<div class="list-group-item text-muted fst-italic">
|
@for (correspondent of suggestions().suggested_correspondents; track correspondent) {
|
||||||
<small class="text-muted small fst-italic" i18n>{fieldSuggestions, plural, =1 {1 suggestion available below} other {{{fieldSuggestions}} suggestions available below}}</small>
|
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addCorrespondent.emit(correspondent)">{{ correspondent }}</button>
|
||||||
</div>
|
}
|
||||||
}
|
}
|
||||||
|
@if (fieldSuggestions > 0) {
|
||||||
|
<div class="list-group-item text-muted fst-italic">
|
||||||
|
<small class="text-muted small fst-italic" i18n>{fieldSuggestions, plural, =1 {1 suggestion available below} other {{{fieldSuggestions}} suggestions available below}}</small>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (aiEnabled()) {
|
@if (aiEnabled()) {
|
||||||
|
|||||||
-31
@@ -251,37 +251,6 @@ describe('SuggestionsDropdownComponent', () => {
|
|||||||
expect(fixture.nativeElement.textContent).toContain('No novel suggestions')
|
expect(fixture.nativeElement.textContent).toContain('No novel suggestions')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should open ML date suggestions with AI disabled', () => {
|
|
||||||
const getSuggestions = jest.spyOn(component.getSuggestions, 'emit')
|
|
||||||
fixture.componentRef.setInput('aiEnabled', false)
|
|
||||||
fixture.componentRef.setInput('fetchedSources', [SuggestionSource.ML])
|
|
||||||
fixture.componentRef.setInput('suggestions', {
|
|
||||||
dates: ['2026-01-04', '2026-02-01'],
|
|
||||||
})
|
|
||||||
fixture.componentRef.setInput('appliedCreated', '2026-01-04')
|
|
||||||
fixture.detectChanges()
|
|
||||||
|
|
||||||
const button = fixture.nativeElement.querySelector('button')
|
|
||||||
expect(button.disabled).toBeFalsy()
|
|
||||||
expect(button.querySelector('.badge').textContent.trim()).toBe('1')
|
|
||||||
|
|
||||||
button.click()
|
|
||||||
fixture.detectChanges()
|
|
||||||
|
|
||||||
expect(component.dropdown.isOpen()).toBeTruthy()
|
|
||||||
expect(fixture.nativeElement.textContent).toContain(
|
|
||||||
'1 suggestion available below'
|
|
||||||
)
|
|
||||||
expect(getSuggestions).not.toHaveBeenCalled()
|
|
||||||
expect(
|
|
||||||
fixture.nativeElement.querySelector('button[title="Suggestion options"]')
|
|
||||||
).toBeNull()
|
|
||||||
|
|
||||||
button.click()
|
|
||||||
fixture.detectChanges()
|
|
||||||
expect(component.dropdown.isOpen()).toBeFalsy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should fetch unfetched sources and show existing suggestions', () => {
|
it('should fetch unfetched sources and show existing suggestions', () => {
|
||||||
jest.spyOn(component.getSuggestions, 'emit')
|
jest.spyOn(component.getSuggestions, 'emit')
|
||||||
fixture.componentRef.setInput('aiEnabled', true)
|
fixture.componentRef.setInput('aiEnabled', true)
|
||||||
|
|||||||
+5
-1
@@ -53,7 +53,11 @@ export class SuggestionsDropdownComponent {
|
|||||||
addCorrespondent: EventEmitter<string> = new EventEmitter()
|
addCorrespondent: EventEmitter<string> = new EventEmitter()
|
||||||
|
|
||||||
public clickSuggest(): void {
|
public clickSuggest(): void {
|
||||||
if (this.disabled() || this.loading()) {
|
if (
|
||||||
|
this.disabled() ||
|
||||||
|
this.loading() ||
|
||||||
|
(this.suggestions() && !this.aiEnabled())
|
||||||
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,33 +1,22 @@
|
|||||||
<ngb-toast
|
<ngb-toast
|
||||||
[autohide]="false"
|
[autohide]="autohide"
|
||||||
[class.mb-2]="autohide"
|
[delay]="toast.delay"
|
||||||
[class.toast-list-item]="!autohide"
|
[class]="toast.classname"
|
||||||
[class.toast-new]="isNew"
|
[class.mb-2]="true"
|
||||||
[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 w-100 start-0 bottom-0 pe-none" [max]="toast.delay" [value]="delayRemaining()"></ngb-progressbar>
|
<ngb-progressbar class="position-absolute h-100 w-100 top-90 start-0 bottom-0 end-0 pe-none" type="dark" [max]="toast.delay" [value]="toast.delayRemaining"></ngb-progressbar>
|
||||||
<span class="visually-hidden">{{ delayRemaining() / 1000 | number: '1.0-0' }} seconds</span>
|
<span class="visually-hidden">{{ toast.delayRemaining / 1000 | number: '1.0-0' }} seconds</span>
|
||||||
}
|
}
|
||||||
<div class="d-flex align-items-top">
|
<div class="d-flex align-items-top">
|
||||||
@switch (toast.level) {
|
@if (!toast.error) {
|
||||||
@case (ToastLevel.Success) {
|
<i-bs width="0.9em" height="0.9em" name="info-circle"></i-bs>
|
||||||
<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>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
<div class="toast-content">
|
@if (toast.error) {
|
||||||
|
<i-bs width="0.9em" height="0.9em" name="exclamation-triangle"></i-bs>
|
||||||
|
}
|
||||||
|
<div>
|
||||||
<p class="ms-2 mb-0 text-break">{{toast.content}}</p>
|
<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">
|
||||||
@@ -59,12 +48,9 @@
|
|||||||
</details>
|
</details>
|
||||||
}
|
}
|
||||||
@if (toast.action) {
|
@if (toast.action) {
|
||||||
<button class="btn btn-sm toast-action ms-2 mt-2" (click)="actioned.emit(toast); toast.action()">{{toast.actionName}}</button>
|
<p class="mb-0 mt-2"><button class="btn btn-sm btn-outline-secondary" (click)="closed.emit(toast); toast.action()">{{toast.actionName}}</button></p>
|
||||||
}
|
}
|
||||||
</div>
|
</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,105 +2,19 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast {
|
::ng-deep .toast.error {
|
||||||
--pngx-toast-accent: var(--pngx-toast-info);
|
border-color: hsla(350, 79%, 40%, 0.4); // bg-danger
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast-icon {
|
::ng-deep .toast.error .toast-body {
|
||||||
color: var(--pngx-toast-accent);
|
background-color: hsla(350, 79%, 40%, 0.8); // bg-danger
|
||||||
|
border-top-left-radius: inherit;
|
||||||
|
border-top-right-radius: inherit;
|
||||||
|
border-bottom-left-radius: inherit;
|
||||||
|
border-bottom-right-radius: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast-content {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast .btn-close {
|
|
||||||
font-size: .6rem;
|
|
||||||
margin-top: .3rem;
|
|
||||||
margin-left: .75rem !important;
|
|
||||||
opacity: .45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast:hover .btn-close {
|
|
||||||
opacity: .85;
|
|
||||||
}
|
|
||||||
|
|
||||||
// compact rows in the notifications dropdown
|
|
||||||
.toast.toast-list-item {
|
|
||||||
width: 100%;
|
|
||||||
max-width: none;
|
|
||||||
background-color: var(--pngx-toast-row-bg);
|
|
||||||
border: 0;
|
|
||||||
box-shadow: none;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--pngx-toast-row-hover-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.toast-error {
|
|
||||||
background-color: var(--pngx-toast-error-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
::ng-deep .toast-body {
|
|
||||||
padding: .625rem .75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-time {
|
|
||||||
margin-top: .1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
// marks notifications that arrived since the dropdown was last opened
|
|
||||||
&.toast-new .toast-time::before {
|
|
||||||
content: '';
|
|
||||||
display: inline-block;
|
|
||||||
width: .45rem;
|
|
||||||
height: .45rem;
|
|
||||||
margin-right: .4rem;
|
|
||||||
vertical-align: .05rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: var(--pngx-toast-info);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-action {
|
|
||||||
--bs-btn-padding-y: .1rem;
|
|
||||||
--bs-btn-padding-x: .6rem;
|
|
||||||
--bs-btn-font-size: .8rem;
|
|
||||||
--bs-btn-font-weight: 600;
|
|
||||||
--bs-btn-color: var(--pngx-toast-accent);
|
|
||||||
--bs-btn-bg: color-mix(in srgb, var(--pngx-toast-accent) 14%, transparent);
|
|
||||||
--bs-btn-border-color: transparent;
|
|
||||||
--bs-btn-hover-color: var(--pngx-toast-accent);
|
|
||||||
--bs-btn-hover-bg: color-mix(in srgb, var(--pngx-toast-accent) 24%, transparent);
|
|
||||||
--bs-btn-hover-border-color: transparent;
|
|
||||||
--bs-btn-active-color: var(--pngx-toast-accent);
|
|
||||||
--bs-btn-active-bg: color-mix(in srgb, var(--pngx-toast-accent) 30%, transparent);
|
|
||||||
--bs-btn-active-border-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
// countdown thin line
|
|
||||||
.progress {
|
.progress {
|
||||||
height: 2px;
|
background-color: var(--pngx-primary);
|
||||||
background-color: transparent;
|
opacity: .07;
|
||||||
|
|
||||||
::ng-deep .progress-bar {
|
|
||||||
background-color: var(--bs-secondary-color);
|
|
||||||
opacity: .5;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,7 @@
|
|||||||
import { DatePipe } from '@angular/common'
|
|
||||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
|
||||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { 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 = {
|
||||||
@@ -34,11 +30,6 @@ 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)
|
||||||
@@ -55,117 +46,11 @@ describe('ToastComponent', () => {
|
|||||||
component.toast = toast2
|
component.toast = toast2
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
component.onShown(toast2)
|
component.onShown(toast2)
|
||||||
jest.advanceTimersByTime(5000 + 150) // ticks every 150ms
|
|
||||||
expect(component.delayRemaining()).toEqual(0)
|
|
||||||
jest.useRealTimers()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should start the progress bar full before the countdown begins', () => {
|
|
||||||
component.toast = { content: 'Info', delay: 3000 }
|
|
||||||
fixture.detectChanges()
|
|
||||||
expect(component.delayRemaining()).toEqual(3000)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should hide the toast when the countdown ends', () => {
|
|
||||||
jest.useFakeTimers()
|
|
||||||
const toast = { content: 'Info', delay: 3000 }
|
|
||||||
component.toast = toast
|
|
||||||
fixture.detectChanges()
|
|
||||||
const hideSpy = jest.spyOn(component.ngbToast, 'hide')
|
|
||||||
component.onShown(toast)
|
|
||||||
jest.advanceTimersByTime(2850)
|
|
||||||
expect(hideSpy).not.toHaveBeenCalled()
|
|
||||||
jest.advanceTimersByTime(300)
|
|
||||||
expect(hideSpy).toHaveBeenCalled()
|
|
||||||
jest.useRealTimers()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should pause the countdown while hovered', () => {
|
|
||||||
jest.useFakeTimers()
|
|
||||||
const toast = { content: 'Info', delay: 3000 }
|
|
||||||
component.toast = toast
|
|
||||||
fixture.detectChanges()
|
|
||||||
const hideSpy = jest.spyOn(component.ngbToast, 'hide')
|
|
||||||
const el: HTMLElement = fixture.nativeElement.querySelector('ngb-toast')
|
|
||||||
component.onShown(toast)
|
|
||||||
jest.advanceTimersByTime(1500)
|
|
||||||
el.dispatchEvent(new MouseEvent('mouseenter'))
|
|
||||||
const remaining = component.delayRemaining()
|
|
||||||
jest.advanceTimersByTime(10000)
|
|
||||||
expect(component.delayRemaining()).toEqual(remaining)
|
|
||||||
expect(hideSpy).not.toHaveBeenCalled()
|
|
||||||
|
|
||||||
el.dispatchEvent(new MouseEvent('mouseleave'))
|
|
||||||
jest.advanceTimersByTime(remaining + 150)
|
|
||||||
expect(hideSpy).toHaveBeenCalled()
|
|
||||||
jest.useRealTimers()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should pause the countdown while focus is inside', () => {
|
|
||||||
jest.useFakeTimers()
|
|
||||||
const toast = { content: 'Info', delay: 3000 }
|
|
||||||
component.toast = toast
|
|
||||||
fixture.detectChanges()
|
|
||||||
const el: HTMLElement = fixture.nativeElement.querySelector('ngb-toast')
|
|
||||||
component.onShown(toast)
|
|
||||||
el.dispatchEvent(new FocusEvent('focusin'))
|
|
||||||
// mouse leaving doesn't resume while focus is still inside
|
|
||||||
el.dispatchEvent(new MouseEvent('mouseenter'))
|
|
||||||
el.dispatchEvent(new MouseEvent('mouseleave'))
|
|
||||||
jest.advanceTimersByTime(5000)
|
jest.advanceTimersByTime(5000)
|
||||||
expect(component.delayRemaining()).toEqual(3000)
|
expect(component.toast.delayRemaining).toEqual(0)
|
||||||
el.dispatchEvent(new FocusEvent('focusout'))
|
|
||||||
jest.advanceTimersByTime(3000)
|
|
||||||
expect(component.delayRemaining()).toEqual(0)
|
|
||||||
jest.useRealTimers()
|
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()
|
||||||
@@ -203,70 +88,4 @@ 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,26 +1,20 @@
|
|||||||
import { Clipboard } from '@angular/cdk/clipboard'
|
import { Clipboard } from '@angular/cdk/clipboard'
|
||||||
import { DatePipe, DecimalPipe } from '@angular/common'
|
import { 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 { Subscription, interval, takeWhile } from 'rxjs'
|
import { interval, take } from 'rxjs'
|
||||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
import { Toast } from 'src/app/services/toast.service'
|
||||||
import { Toast, ToastLevel } from 'src/app/services/toast.service'
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-toast',
|
selector: 'pngx-toast',
|
||||||
@@ -32,79 +26,32 @@ import { Toast, ToastLevel } 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 implements OnInit {
|
export class ToastComponent {
|
||||||
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
|
||||||
this.delayRemaining.set(toast.delay)
|
const delay = toast.delay - 500 // for fade animation
|
||||||
|
|
||||||
this.countdown?.unsubscribe()
|
interval(refreshInterval)
|
||||||
this.countdown = interval(refreshInterval)
|
.pipe(take(Math.round(delay / refreshInterval)))
|
||||||
.pipe(
|
.subscribe((count) => {
|
||||||
takeWhile(() => this.delayRemaining() > 0),
|
toast.delayRemaining = Math.max(
|
||||||
takeUntilDestroyed(this.destroyRef)
|
0,
|
||||||
)
|
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)="removePopup(toast)" (actioned)="closeToast(toast)" (hidden)="removePopup(toast)"></pngx-toast>
|
<pngx-toast [toast]="toast" [autohide]="true" (closed)="closeToast()"></pngx-toast>
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
import { 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 { MAX_VISIBLE_TOASTS, ToastsComponent } from './toasts.component'
|
import { ToastsComponent } from './toasts.component'
|
||||||
|
|
||||||
const toast = {
|
const toast = {
|
||||||
content: 'Error 2 content',
|
content: 'Error 2 content',
|
||||||
@@ -51,7 +50,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(toast)
|
component.closeToast()
|
||||||
expect(component.toasts()).toEqual([])
|
expect(component.toasts()).toEqual([])
|
||||||
expect(closeToastSpy).toHaveBeenCalledWith(toast)
|
expect(closeToastSpy).toHaveBeenCalledWith(toast)
|
||||||
})
|
})
|
||||||
@@ -60,59 +59,4 @@ 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,18 +1,14 @@
|
|||||||
import { Component, inject, signal } from '@angular/core'
|
import { Component, inject } from '@angular/core'
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
|
import { toSignal } 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',
|
||||||
@@ -27,31 +23,18 @@ export const MAX_VISIBLE_TOASTS = 3
|
|||||||
export class ToastsComponent {
|
export class ToastsComponent {
|
||||||
toastService = inject(ToastService)
|
toastService = inject(ToastService)
|
||||||
|
|
||||||
readonly toasts = signal<Toast[]>([])
|
private readonly closedToast = new Subject<void>()
|
||||||
|
|
||||||
constructor() {
|
readonly toasts = toSignal(
|
||||||
this.toastService.showToast
|
merge(
|
||||||
.pipe(takeUntilDestroyed())
|
this.toastService.showToast.pipe(map((toast) => (toast ? [toast] : []))),
|
||||||
.subscribe((toast) => {
|
this.closedToast.pipe(map(() => [] as Toast[]))
|
||||||
if (!toast) {
|
),
|
||||||
this.toasts.set([])
|
{ initialValue: [] as Toast[] }
|
||||||
return
|
)
|
||||||
}
|
|
||||||
this.toasts.update((toasts) =>
|
|
||||||
[toast, ...toasts.filter((t) => t.id !== toast.id)].slice(
|
|
||||||
0,
|
|
||||||
MAX_VISIBLE_TOASTS
|
|
||||||
)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
closeToast(toast: Toast) {
|
closeToast() {
|
||||||
this.toastService.closeToast(toast)
|
this.toastService.closeToast(this.toasts()[0])
|
||||||
this.removePopup(toast)
|
this.closedToast.next()
|
||||||
}
|
|
||||||
|
|
||||||
removePopup(toast: Toast) {
|
|
||||||
this.toasts.update((toasts) => toasts.filter((t) => t.id !== toast.id))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
@if (canAddDocuments && getStatus().length > 0) {
|
|
||||||
<div class="fixed-bottom p-2 p-md-4 d-flex justify-content-end pe-none consumer-status-list" [ngClass]="slimSidebarEnabled ? 'col-slim' : 'offset-sidebar-expanded'">
|
|
||||||
<div class="col col-lg-4 col-xl-3 ps-0 pe-0 ps-lg-3 pe-lg-0 pe-auto overflow-y-auto">
|
|
||||||
<div class="card shadow-sm consumer-status-card">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="d-flex flex-wrap align-items-center column-gap-2">
|
|
||||||
<p class="m-0 small text-muted">{{getStatusSummary()}}</p>
|
|
||||||
<div class="d-flex align-items-center gap-2 ms-auto">
|
|
||||||
@if (getStatusCompleted().length > 0) {
|
|
||||||
<a class="btn-link small text-nowrap" (click)="dismissCompleted()" [routerLink]="[]">
|
|
||||||
<span class="me-1" i18n="This button dismisses all status messages about processed documents in the tray (failed and successful)">Dismiss completed</span>
|
|
||||||
<i-bs name="check2-all"></i-bs>
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
<button type="button" class="btn btn-sm btn-link p-0 text-muted" (click)="toggleCollapsed()"
|
|
||||||
[attr.aria-expanded]="!collapsed()" aria-label="Toggle document processing status" i18n-aria-label>
|
|
||||||
<i-bs [name]="collapsed() ? 'chevron-up' : 'chevron-down'"></i-bs>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@if (!collapsed()) {
|
|
||||||
@for (status of getStatus(); track status) {
|
|
||||||
<ng-container [ngTemplateOutlet]="consumerAlert" [ngTemplateOutletContext]="{ $implicit: status }"></ng-container>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<ng-template #consumerAlert let-status>
|
|
||||||
<ngb-alert type="secondary" class="mt-2 mb-0" [dismissible]="isFinished(status)" (closed)="dismiss(status)">
|
|
||||||
<h6 class="alert-heading">{{status.filename}}</h6>
|
|
||||||
@if (!isFinished(status) || (isFinished(status) && !status.documentId)) {
|
|
||||||
<p class="mb-0 pb-1">{{status.message}}</p>
|
|
||||||
}
|
|
||||||
<ngb-progressbar [value]="status.getProgress()" [max]="1" [type]="getStatusColor(status)"></ngb-progressbar>
|
|
||||||
<div *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
|
||||||
@if (isFinished(status)) {
|
|
||||||
<div>
|
|
||||||
@if (status.documentId) {
|
|
||||||
<a class="btn btn-sm btn-outline-primary btn-open" [routerLink]="['/documents', status.documentId]" (click)="dismiss(status)">
|
|
||||||
<small i18n>Open document</small>
|
|
||||||
<i-bs name="arrow-right-short"></i-bs>
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</ngb-alert>
|
|
||||||
</ng-template>
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
:host ::ng-deep i-bs svg {
|
|
||||||
margin-top: -3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-heading {
|
|
||||||
font-size: 80%;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-open {
|
|
||||||
line-height: 1;
|
|
||||||
|
|
||||||
i-bs {
|
|
||||||
margin-top: -1px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.consumer-status-card {
|
|
||||||
background-color: rgba(var(--bs-body-bg-rgb), .95) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.consumer-status-list {
|
|
||||||
max-height: 60vh;
|
|
||||||
z-index: 1030;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
height: auto;
|
|
||||||
mix-blend-mode: soft-light;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
-206
@@ -1,206 +0,0 @@
|
|||||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
|
||||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
|
||||||
import { RouterTestingModule } from '@angular/router/testing'
|
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
|
||||||
import { routes } from 'src/app/app-routing.module'
|
|
||||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
|
||||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
|
||||||
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
|
|
||||||
import {
|
|
||||||
FileStatus,
|
|
||||||
FileStatusPhase,
|
|
||||||
WebsocketStatusService,
|
|
||||||
} from 'src/app/services/websocket-status.service'
|
|
||||||
import { UploadStatusTrayComponent } from './upload-status-tray.component'
|
|
||||||
|
|
||||||
const FAILED_STATUSES = [new FileStatus()]
|
|
||||||
const WORKING_STATUSES = [new FileStatus(), new FileStatus()]
|
|
||||||
const STARTED_STATUSES = [new FileStatus(), new FileStatus(), new FileStatus()]
|
|
||||||
const SUCCESS_STATUSES = [
|
|
||||||
new FileStatus(),
|
|
||||||
new FileStatus(),
|
|
||||||
new FileStatus(),
|
|
||||||
new FileStatus(),
|
|
||||||
]
|
|
||||||
const DEFAULT_STATUSES = [
|
|
||||||
new FileStatus(),
|
|
||||||
new FileStatus(),
|
|
||||||
new FileStatus(),
|
|
||||||
new FileStatus(),
|
|
||||||
new FileStatus(),
|
|
||||||
new FileStatus(),
|
|
||||||
]
|
|
||||||
|
|
||||||
describe('UploadStatusTrayComponent', () => {
|
|
||||||
let component: UploadStatusTrayComponent
|
|
||||||
let fixture: ComponentFixture<UploadStatusTrayComponent>
|
|
||||||
let websocketStatusService: WebsocketStatusService
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
imports: [
|
|
||||||
RouterTestingModule.withRoutes(routes),
|
|
||||||
NgxBootstrapIconsModule.pick(allIcons),
|
|
||||||
UploadStatusTrayComponent,
|
|
||||||
],
|
|
||||||
providers: [
|
|
||||||
PermissionsGuard,
|
|
||||||
{
|
|
||||||
provide: PermissionsService,
|
|
||||||
useValue: {
|
|
||||||
currentUserCan: () => true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
|
||||||
provideHttpClientTesting(),
|
|
||||||
],
|
|
||||||
}).compileComponents()
|
|
||||||
|
|
||||||
websocketStatusService = TestBed.inject(WebsocketStatusService)
|
|
||||||
fixture = TestBed.createComponent(UploadStatusTrayComponent)
|
|
||||||
component = fixture.componentInstance
|
|
||||||
|
|
||||||
fixture.detectChanges()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should not render without statuses', () => {
|
|
||||||
expect(
|
|
||||||
fixture.nativeElement.querySelector('.consumer-status-card')
|
|
||||||
).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should render statuses and support collapsing', () => {
|
|
||||||
const status = new FileStatus()
|
|
||||||
status.filename = 'invoice.pdf'
|
|
||||||
status.phase = FileStatusPhase.WORKING
|
|
||||||
jest
|
|
||||||
.spyOn(websocketStatusService, 'getConsumerStatus')
|
|
||||||
.mockReturnValue([status])
|
|
||||||
// a mocked return value doesn't mark the zoneless view dirty, so re-create it
|
|
||||||
fixture = TestBed.createComponent(UploadStatusTrayComponent)
|
|
||||||
component = fixture.componentInstance
|
|
||||||
fixture.detectChanges()
|
|
||||||
const el: HTMLElement = fixture.nativeElement
|
|
||||||
expect(el.querySelector('.consumer-status-card')).not.toBeNull()
|
|
||||||
expect(el.textContent).toContain('invoice.pdf')
|
|
||||||
|
|
||||||
component.toggleCollapsed()
|
|
||||||
fixture.detectChanges()
|
|
||||||
expect(el.querySelector('.consumer-status-card')).not.toBeNull()
|
|
||||||
expect(el.textContent).not.toContain('invoice.pdf')
|
|
||||||
|
|
||||||
component.toggleCollapsed()
|
|
||||||
fixture.detectChanges()
|
|
||||||
expect(el.textContent).toContain('invoice.pdf')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should expand again once all statuses are dismissed', () => {
|
|
||||||
const status = websocketStatusService.newFileUpload('invoice.pdf')
|
|
||||||
fixture.detectChanges()
|
|
||||||
component.toggleCollapsed()
|
|
||||||
expect(component.collapsed()).toBe(true)
|
|
||||||
websocketStatusService.dismiss(status)
|
|
||||||
fixture.detectChanges()
|
|
||||||
expect(component.collapsed()).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should expand on request', () => {
|
|
||||||
component.toggleCollapsed()
|
|
||||||
expect(component.collapsed()).toBe(true)
|
|
||||||
component.expand()
|
|
||||||
expect(component.collapsed()).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should expand when new uploads start', () => {
|
|
||||||
const uploadDocumentsService = TestBed.inject(UploadDocumentsService)
|
|
||||||
jest
|
|
||||||
.spyOn(uploadDocumentsService, 'uploadFile')
|
|
||||||
.mockImplementation(() => {})
|
|
||||||
websocketStatusService.newFileUpload('invoice.pdf')
|
|
||||||
fixture.detectChanges()
|
|
||||||
component.toggleCollapsed()
|
|
||||||
expect(component.collapsed()).toBe(true)
|
|
||||||
uploadDocumentsService.uploadFiles([new File(['one'], 'one.pdf')])
|
|
||||||
expect(component.collapsed()).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should generate stats summary', () => {
|
|
||||||
mockConsumerStatuses(websocketStatusService)
|
|
||||||
expect(component.getStatusSummary()).toEqual(
|
|
||||||
'Processing: 6, Failed: 1, Added: 4'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should report an upload progress summary', () => {
|
|
||||||
mockConsumerStatuses(websocketStatusService)
|
|
||||||
expect(component.getTotalUploadProgress()).toEqual(0.75)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should change color by status phase', () => {
|
|
||||||
const processingStatus = new FileStatus()
|
|
||||||
processingStatus.phase = FileStatusPhase.WORKING
|
|
||||||
expect(component.getStatusColor(processingStatus)).toEqual('primary')
|
|
||||||
processingStatus.phase = FileStatusPhase.UPLOADING
|
|
||||||
expect(component.getStatusColor(processingStatus)).toEqual('primary')
|
|
||||||
const failedStatus = new FileStatus()
|
|
||||||
failedStatus.phase = FileStatusPhase.FAILED
|
|
||||||
expect(component.getStatusColor(failedStatus)).toEqual('danger')
|
|
||||||
const successStatus = new FileStatus()
|
|
||||||
successStatus.phase = FileStatusPhase.SUCCESS
|
|
||||||
expect(component.getStatusColor(successStatus)).toEqual('success')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should allow dismissing an alert', () => {
|
|
||||||
const dismissSpy = jest.spyOn(websocketStatusService, 'dismiss')
|
|
||||||
component.dismiss(new FileStatus())
|
|
||||||
expect(dismissSpy).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should allow dismissing completed alerts', () => {
|
|
||||||
jest.useFakeTimers()
|
|
||||||
mockConsumerStatuses(websocketStatusService)
|
|
||||||
fixture.detectChanges()
|
|
||||||
jest
|
|
||||||
.spyOn(component, 'getStatusCompleted')
|
|
||||||
.mockImplementation(() => SUCCESS_STATUSES)
|
|
||||||
const dismissSpy = jest.spyOn(websocketStatusService, 'dismiss')
|
|
||||||
component.dismissCompleted()
|
|
||||||
jest.advanceTimersByTime(1000)
|
|
||||||
fixture.detectChanges()
|
|
||||||
expect(dismissSpy).toHaveBeenCalledTimes(4)
|
|
||||||
jest.useRealTimers()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
function mockConsumerStatuses(consumerStatusService) {
|
|
||||||
const partialUpload1 = new FileStatus()
|
|
||||||
partialUpload1.currentPhaseProgress = 50
|
|
||||||
partialUpload1.currentPhaseMaxProgress = 50
|
|
||||||
const partialUpload2 = new FileStatus()
|
|
||||||
partialUpload2.currentPhaseProgress = 25
|
|
||||||
partialUpload2.currentPhaseMaxProgress = 50
|
|
||||||
jest
|
|
||||||
.spyOn(consumerStatusService, 'getConsumerStatus')
|
|
||||||
.mockImplementation((phase) => {
|
|
||||||
switch (phase) {
|
|
||||||
case FileStatusPhase.FAILED:
|
|
||||||
return FAILED_STATUSES
|
|
||||||
case FileStatusPhase.WORKING:
|
|
||||||
return WORKING_STATUSES
|
|
||||||
case FileStatusPhase.STARTED:
|
|
||||||
return STARTED_STATUSES
|
|
||||||
case FileStatusPhase.SUCCESS:
|
|
||||||
return SUCCESS_STATUSES
|
|
||||||
case FileStatusPhase.UPLOADING:
|
|
||||||
return [partialUpload1, partialUpload2]
|
|
||||||
default:
|
|
||||||
return DEFAULT_STATUSES
|
|
||||||
}
|
|
||||||
})
|
|
||||||
jest
|
|
||||||
.spyOn(consumerStatusService, 'getConsumerStatusNotCompleted')
|
|
||||||
.mockImplementation(() => {
|
|
||||||
return DEFAULT_STATUSES
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
import { NgClass, NgTemplateOutlet } from '@angular/common'
|
|
||||||
import { Component, effect, inject, signal } from '@angular/core'
|
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
|
|
||||||
import { RouterModule } from '@angular/router'
|
|
||||||
import {
|
|
||||||
NgbAlertModule,
|
|
||||||
NgbProgressbarModule,
|
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
|
||||||
import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component'
|
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
|
||||||
import {
|
|
||||||
PermissionAction,
|
|
||||||
PermissionsService,
|
|
||||||
PermissionType,
|
|
||||||
} from 'src/app/services/permissions.service'
|
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
|
||||||
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
|
|
||||||
import {
|
|
||||||
FileStatus,
|
|
||||||
FileStatusPhase,
|
|
||||||
WebsocketStatusService,
|
|
||||||
} from 'src/app/services/websocket-status.service'
|
|
||||||
|
|
||||||
/** Floating list of document consumption statuses, shown on every page */
|
|
||||||
@Component({
|
|
||||||
selector: 'pngx-upload-status-tray',
|
|
||||||
templateUrl: './upload-status-tray.component.html',
|
|
||||||
styleUrls: ['./upload-status-tray.component.scss'],
|
|
||||||
imports: [
|
|
||||||
IfPermissionsDirective,
|
|
||||||
NgClass,
|
|
||||||
NgTemplateOutlet,
|
|
||||||
RouterModule,
|
|
||||||
NgbAlertModule,
|
|
||||||
NgbProgressbarModule,
|
|
||||||
NgxBootstrapIconsModule,
|
|
||||||
],
|
|
||||||
})
|
|
||||||
export class UploadStatusTrayComponent extends ComponentWithPermissions {
|
|
||||||
private websocketStatusService = inject(WebsocketStatusService)
|
|
||||||
private settingsService = inject(SettingsService)
|
|
||||||
private permissionsService = inject(PermissionsService)
|
|
||||||
private uploadDocumentsService = inject(UploadDocumentsService)
|
|
||||||
|
|
||||||
readonly collapsed = signal(false)
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super()
|
|
||||||
// start expanded again for the next uploads once everything is dismissed
|
|
||||||
effect(() => {
|
|
||||||
if (this.getStatus().length === 0) this.collapsed.set(false)
|
|
||||||
})
|
|
||||||
this.uploadDocumentsService
|
|
||||||
.onUploadsStarted()
|
|
||||||
.pipe(takeUntilDestroyed())
|
|
||||||
.subscribe(() => this.expand())
|
|
||||||
}
|
|
||||||
|
|
||||||
get canAddDocuments(): boolean {
|
|
||||||
return this.permissionsService.currentUserCan(
|
|
||||||
PermissionAction.Add,
|
|
||||||
PermissionType.Document
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
getStatus() {
|
|
||||||
return this.websocketStatusService.getConsumerStatus()
|
|
||||||
}
|
|
||||||
|
|
||||||
getStatusSummary() {
|
|
||||||
let strings = []
|
|
||||||
let countUploadingAndProcessing =
|
|
||||||
this.websocketStatusService.getConsumerStatusNotCompleted().length
|
|
||||||
let countFailed = this.getStatusFailed().length
|
|
||||||
let countSuccess = this.getStatusSuccess().length
|
|
||||||
if (countUploadingAndProcessing > 0) {
|
|
||||||
strings.push($localize`Processing: ${countUploadingAndProcessing}`)
|
|
||||||
}
|
|
||||||
if (countFailed > 0) {
|
|
||||||
strings.push($localize`Failed: ${countFailed}`)
|
|
||||||
}
|
|
||||||
if (countSuccess > 0) {
|
|
||||||
strings.push($localize`Added: ${countSuccess}`)
|
|
||||||
}
|
|
||||||
return strings.join(
|
|
||||||
$localize`:this string is used to separate processing, failed and added on the file upload widget:, `
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
getStatusUploading() {
|
|
||||||
return this.websocketStatusService.getConsumerStatus(
|
|
||||||
FileStatusPhase.UPLOADING
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
getStatusFailed() {
|
|
||||||
return this.websocketStatusService.getConsumerStatus(FileStatusPhase.FAILED)
|
|
||||||
}
|
|
||||||
|
|
||||||
getStatusSuccess() {
|
|
||||||
return this.websocketStatusService.getConsumerStatus(
|
|
||||||
FileStatusPhase.SUCCESS
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
getStatusCompleted() {
|
|
||||||
return this.websocketStatusService.getConsumerStatusCompleted()
|
|
||||||
}
|
|
||||||
|
|
||||||
getTotalUploadProgress() {
|
|
||||||
let current = 0
|
|
||||||
let max = 0
|
|
||||||
|
|
||||||
this.getStatusUploading().forEach((status) => {
|
|
||||||
current += status.currentPhaseProgress
|
|
||||||
max += status.currentPhaseMaxProgress
|
|
||||||
})
|
|
||||||
|
|
||||||
return current / Math.max(max, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
isFinished(status: FileStatus) {
|
|
||||||
return (
|
|
||||||
status.phase == FileStatusPhase.FAILED ||
|
|
||||||
status.phase == FileStatusPhase.SUCCESS
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
getStatusColor(status: FileStatus) {
|
|
||||||
switch (status.phase) {
|
|
||||||
case FileStatusPhase.UPLOADING:
|
|
||||||
case FileStatusPhase.STARTED:
|
|
||||||
case FileStatusPhase.WORKING:
|
|
||||||
return 'primary'
|
|
||||||
case FileStatusPhase.FAILED:
|
|
||||||
return 'danger'
|
|
||||||
case FileStatusPhase.SUCCESS:
|
|
||||||
return 'success'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleCollapsed() {
|
|
||||||
this.collapsed.update((collapsed) => !collapsed)
|
|
||||||
}
|
|
||||||
|
|
||||||
expand() {
|
|
||||||
this.collapsed.set(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
dismiss(status: FileStatus) {
|
|
||||||
this.websocketStatusService.dismiss(status)
|
|
||||||
}
|
|
||||||
|
|
||||||
dismissCompleted() {
|
|
||||||
this.getStatusCompleted().forEach((status) =>
|
|
||||||
this.websocketStatusService.dismiss(status)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
get slimSidebarEnabled(): boolean {
|
|
||||||
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -173,7 +173,7 @@ describe('DashboardComponent', () => {
|
|||||||
|
|
||||||
it('should update saved view sorting on drag + drop, show info', () => {
|
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, 'showSuccess')
|
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
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.showSuccess($localize`Dashboard updated`)
|
this.toastService.showInfo($localize`Dashboard updated`)
|
||||||
},
|
},
|
||||||
error: (e) => {
|
error: (e) => {
|
||||||
this.toastService.showError($localize`Error updating dashboard`, e)
|
this.toastService.showError($localize`Error updating dashboard`, e)
|
||||||
|
|||||||
-2
@@ -25,8 +25,6 @@
|
|||||||
@if (displayFields().includes(field)) {
|
@if (displayFields().includes(field)) {
|
||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
class="text-truncate"
|
|
||||||
[title]="getColumnTitle(field)"
|
|
||||||
[ngClass]="{
|
[ngClass]="{
|
||||||
'd-none d-md-table-cell': i > 1,
|
'd-none d-md-table-cell': i > 1,
|
||||||
'w-25': field === DisplayField.CREATED || field === DisplayField.ADDED
|
'w-25': field === DisplayField.CREATED || field === DisplayField.ADDED
|
||||||
|
|||||||
+54
@@ -8,5 +8,59 @@
|
|||||||
</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>
|
||||||
+42
@@ -20,6 +20,48 @@
|
|||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
+112
-4
@@ -9,11 +9,35 @@ 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 () => {
|
||||||
@@ -37,6 +61,7 @@ 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
|
||||||
@@ -60,10 +85,93 @@ describe('UploadFileWidgetComponent', () => {
|
|||||||
'file.pdf'
|
'file.pdf'
|
||||||
)
|
)
|
||||||
const fileInput = fixture.debugElement.query(By.css('input'))
|
const fileInput = fixture.debugElement.query(By.css('input'))
|
||||||
jest
|
jest.spyOn(fileInput.nativeElement, 'files', 'get').mockReturnValue({
|
||||||
.spyOn(fileInput.nativeElement, 'files', 'get')
|
item: () => file,
|
||||||
.mockReturnValue([file] as any)
|
length: 1,
|
||||||
|
[Symbol.iterator]: () => ({
|
||||||
|
next: () => ({ done: false, value: file }),
|
||||||
|
}),
|
||||||
|
} as any)
|
||||||
fileInput.nativeElement.dispatchEvent(new Event('change'))
|
fileInput.nativeElement.dispatchEvent(new Event('change'))
|
||||||
expect(uploadSpy).toHaveBeenCalledWith(file)
|
expect(uploadSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should generate stats summary', () => {
|
||||||
|
mockConsumerStatuses(websocketStatusService)
|
||||||
|
expect(component.getStatusSummary()).toEqual(
|
||||||
|
'Processing: 6, Failed: 1, Added: 4'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should report an upload progress summary', () => {
|
||||||
|
mockConsumerStatuses(websocketStatusService)
|
||||||
|
expect(component.getTotalUploadProgress()).toEqual(0.75)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should change color by status phase', () => {
|
||||||
|
const processingStatus = new FileStatus()
|
||||||
|
processingStatus.phase = FileStatusPhase.WORKING
|
||||||
|
expect(component.getStatusColor(processingStatus)).toEqual('primary')
|
||||||
|
processingStatus.phase = FileStatusPhase.UPLOADING
|
||||||
|
expect(component.getStatusColor(processingStatus)).toEqual('primary')
|
||||||
|
const failedStatus = new FileStatus()
|
||||||
|
failedStatus.phase = FileStatusPhase.FAILED
|
||||||
|
expect(component.getStatusColor(failedStatus)).toEqual('danger')
|
||||||
|
const successStatus = new FileStatus()
|
||||||
|
successStatus.phase = FileStatusPhase.SUCCESS
|
||||||
|
expect(component.getStatusColor(successStatus)).toEqual('success')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should allow dismissing an alert', () => {
|
||||||
|
const dismissSpy = jest.spyOn(websocketStatusService, 'dismiss')
|
||||||
|
component.dismiss(new FileStatus())
|
||||||
|
expect(dismissSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should allow dismissing completed alerts', () => {
|
||||||
|
jest.useFakeTimers()
|
||||||
|
mockConsumerStatuses(websocketStatusService)
|
||||||
|
fixture.detectChanges()
|
||||||
|
jest
|
||||||
|
.spyOn(component, 'getStatusCompleted')
|
||||||
|
.mockImplementation(() => SUCCESS_STATUSES)
|
||||||
|
const dismissSpy = jest.spyOn(websocketStatusService, 'dismiss')
|
||||||
|
component.dismissCompleted()
|
||||||
|
jest.advanceTimersByTime(1000)
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(dismissSpy).toHaveBeenCalledTimes(4)
|
||||||
|
jest.useRealTimers()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function mockConsumerStatuses(consumerStatusService) {
|
||||||
|
const partialUpload1 = new FileStatus()
|
||||||
|
partialUpload1.currentPhaseProgress = 50
|
||||||
|
partialUpload1.currentPhaseMaxProgress = 50
|
||||||
|
const partialUpload2 = new FileStatus()
|
||||||
|
partialUpload2.currentPhaseProgress = 25
|
||||||
|
partialUpload2.currentPhaseMaxProgress = 50
|
||||||
|
jest
|
||||||
|
.spyOn(consumerStatusService, 'getConsumerStatus')
|
||||||
|
.mockImplementation((phase) => {
|
||||||
|
switch (phase) {
|
||||||
|
case FileStatusPhase.FAILED:
|
||||||
|
return FAILED_STATUSES
|
||||||
|
case FileStatusPhase.WORKING:
|
||||||
|
return WORKING_STATUSES
|
||||||
|
case FileStatusPhase.STARTED:
|
||||||
|
return STARTED_STATUSES
|
||||||
|
case FileStatusPhase.SUCCESS:
|
||||||
|
return SUCCESS_STATUSES
|
||||||
|
case FileStatusPhase.UPLOADING:
|
||||||
|
return [partialUpload1, partialUpload2]
|
||||||
|
default:
|
||||||
|
return DEFAULT_STATUSES
|
||||||
|
}
|
||||||
|
})
|
||||||
|
jest
|
||||||
|
.spyOn(consumerStatusService, 'getConsumerStatusNotCompleted')
|
||||||
|
.mockImplementation(() => {
|
||||||
|
return DEFAULT_STATUSES
|
||||||
|
})
|
||||||
|
}
|
||||||
+119
-4
@@ -1,9 +1,23 @@
|
|||||||
import { Component, inject } from '@angular/core'
|
import { NgClass, NgTemplateOutlet } from '@angular/common'
|
||||||
|
import { Component, QueryList, ViewChildren, inject } from '@angular/core'
|
||||||
|
import { RouterModule } from '@angular/router'
|
||||||
|
import {
|
||||||
|
NgbAlert,
|
||||||
|
NgbAlertModule,
|
||||||
|
NgbProgressbarModule,
|
||||||
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { 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({
|
||||||
@@ -13,16 +27,117 @@ 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 input = event.target as HTMLInputElement
|
const files = (event.target as HTMLInputElement).files
|
||||||
this.uploadDocumentsService.uploadFiles(Array.from(input.files ?? []))
|
for (let i = 0; i < files?.length; i++) {
|
||||||
input.value = ''
|
const file = files.item(i)
|
||||||
|
file && this.uploadDocumentsService.uploadFile(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get slimSidebarEnabled(): boolean {
|
||||||
|
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,25 @@
|
|||||||
<pngx-page-header [title]="title()" [id]="documentId()">
|
<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>
|
||||||
@@ -13,7 +34,7 @@
|
|||||||
(versionsUpdated)="onVersionsUpdated($event)"
|
(versionsUpdated)="onVersionsUpdated($event)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="btn-group ms-4">
|
<div class="btn-group">
|
||||||
<button (click)="download()" class="btn btn-sm btn-outline-primary" [disabled]="downloading()" aria-label="Download" i18n-aria-label>
|
<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>
|
||||||
@@ -46,24 +67,24 @@
|
|||||||
</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-2"></i-bs><span i18n>Reprocess</span>
|
<i-bs width="1em" height="1em" name="arrow-counterclockwise" class="me-1"></i-bs><span i18n>Reprocess</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button ngbDropdownItem (click)="printDocument()" [hidden]="useNativePdfViewer || isMobile">
|
<button ngbDropdownItem (click)="printDocument()" [hidden]="useNativePdfViewer || isMobile">
|
||||||
<i-bs width="1em" height="1em" name="printer" class="me-2"></i-bs><span i18n>Print</span>
|
<i-bs width="1em" height="1em" name="printer" class="me-1"></i-bs><span i18n>Print</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button ngbDropdownItem (click)="moreLike()">
|
<button ngbDropdownItem (click)="moreLike()">
|
||||||
<i-bs width="1em" height="1em" name="diagram-3" class="me-2"></i-bs><span i18n>More like this</span>
|
<i-bs width="1em" height="1em" name="diagram-3" class="me-1"></i-bs><span i18n>More like this</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<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-2"></i-bs><ng-container i18n>PDF Editor</ng-container>
|
<i-bs name="pencil" class="me-1"></i-bs><ng-container i18n>PDF Editor</ng-container>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@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-2"></i-bs><ng-container i18n>Remove Password</ng-container>
|
<i-bs name="unlock" class="me-1"></i-bs><ng-container i18n>Remove Password</ng-container>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -73,13 +94,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="sendDropdown" class="shadow">
|
<div ngbDropdownMenu aria-labelledby="actionsDropdown" 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-2"></i-bs><span i18n>Share Links</span>
|
<i-bs name="link" class="me-1"></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-2"></i-bs><span i18n>Email</span>
|
<i-bs name="envelope" class="me-1"></i-bs><span i18n>Email</span>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -92,8 +113,8 @@
|
|||||||
|
|
||||||
<form [formGroup]='documentForm' (ngSubmit)="save()">
|
<form [formGroup]='documentForm' (ngSubmit)="save()">
|
||||||
|
|
||||||
<div class="btn-toolbar mb-1 pb-3 gap-2 row-gap-2 border-bottom">
|
<div class="btn-toolbar justify-content-end mb-1 pb-3 gap-2 row-gap-2 border-bottom">
|
||||||
<div class="btn-group">
|
<div class="btn-group me-auto">
|
||||||
<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>
|
||||||
@@ -106,30 +127,43 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ng-container *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
|
<ng-container *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
|
||||||
<div class="btn-group ms-3">
|
<div class="d-flex gap-2">
|
||||||
<pngx-suggestions-dropdown *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }"
|
<div class="btn-group">
|
||||||
[disabled]="!userCanEdit || suggestionsLoading()"
|
<pngx-suggestions-dropdown *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }"
|
||||||
[loading]="suggestionsLoading()"
|
[disabled]="!userCanEdit || suggestionsLoading()"
|
||||||
[suggestions]="suggestions()"
|
[loading]="suggestionsLoading()"
|
||||||
[aiEnabled]="aiEnabled"
|
[suggestions]="suggestions()"
|
||||||
[source]="suggestionSource"
|
[aiEnabled]="aiEnabled"
|
||||||
[defaultSource]="defaultSuggestionSource"
|
[source]="suggestionSource"
|
||||||
[fetchedSources]="fetchedSuggestionSources()"
|
[defaultSource]="defaultSuggestionSource"
|
||||||
[appliedTags]="documentForm.value.tags"
|
[fetchedSources]="fetchedSuggestionSources()"
|
||||||
[appliedCorrespondent]="documentForm.value.correspondent"
|
[appliedTags]="documentForm.value.tags"
|
||||||
[appliedDocumentType]="documentForm.value.document_type"
|
[appliedCorrespondent]="documentForm.value.correspondent"
|
||||||
[appliedStoragePath]="documentForm.value.storage_path"
|
[appliedDocumentType]="documentForm.value.document_type"
|
||||||
[appliedTitle]="documentForm.value.title"
|
[appliedStoragePath]="documentForm.value.storage_path"
|
||||||
[appliedCreated]="documentForm.value.created"
|
[appliedTitle]="documentForm.value.title"
|
||||||
(getSuggestions)="getSuggestions($event)"
|
[appliedCreated]="documentForm.value.created"
|
||||||
(sourceChange)="suggestionSourceOverride.set($event)"
|
(getSuggestions)="getSuggestions($event)"
|
||||||
(addTag)="createTag($event)"
|
(sourceChange)="suggestionSourceOverride.set($event)"
|
||||||
(addDocumentType)="createDocumentType($event)"
|
(addTag)="createTag($event)"
|
||||||
(addCorrespondent)="createCorrespondent($event)">
|
(addDocumentType)="createDocumentType($event)"
|
||||||
</pngx-suggestions-dropdown>
|
(addCorrespondent)="createCorrespondent($event)">
|
||||||
|
</pngx-suggestions-dropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="btn-group">
|
||||||
|
<pngx-custom-fields-dropdown
|
||||||
|
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }"
|
||||||
|
[documentId]="documentId()"
|
||||||
|
[disabled]="!userCanEdit"
|
||||||
|
[existingFields]="document()?.custom_fields"
|
||||||
|
(created)="refreshCustomFields()"
|
||||||
|
(added)="addField($event)">
|
||||||
|
</pngx-custom-fields-dropdown>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ms-auto">
|
<div class="ps-3">
|
||||||
<ng-container *ngTemplateOutlet="saveButtons"></ng-container>
|
<ng-container *ngTemplateOutlet="saveButtons"></ng-container>
|
||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
@@ -256,20 +290,8 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-flex border-top pt-3">
|
<div class="d-flex justify-content-end border-top pt-3">
|
||||||
<ng-container *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
|
<ng-container *ngTemplateOutlet="saveButtons"></ng-container>
|
||||||
<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>
|
||||||
@@ -306,9 +328,9 @@
|
|||||||
<td i18n>Original filename</td>
|
<td i18n>Original filename</td>
|
||||||
<td>{{metadata()?.original_filename}}</td>
|
<td>{{metadata()?.original_filename}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr class="align-middle">
|
<tr>
|
||||||
<td i18n>Original SHA256 checksum</td>
|
<td i18n>Original SHA256 checksum</td>
|
||||||
<td><span class="badge bg-light font-monospace text-wrap text-break text-start">{{metadata()?.original_checksum}}</span></td>
|
<td>{{metadata()?.original_checksum}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td i18n>Original file size</td>
|
<td i18n>Original file size</td>
|
||||||
@@ -319,9 +341,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 class="align-middle">
|
<tr>
|
||||||
<td i18n>Archive SHA256 checksum</td>
|
<td i18n>Archive SHA256 checksum</td>
|
||||||
<td><span class="badge bg-light font-monospace text-wrap text-break text-start">{{metadata()?.archive_checksum}}</span></td>
|
<td>{{metadata()?.archive_checksum}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@if (metadata()?.has_archive_version) {
|
@if (metadata()?.has_archive_version) {
|
||||||
@@ -386,7 +408,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 title="" [users]="users()" formControlName="permissions_form"></pngx-permissions-form>
|
<pngx-permissions-form [users]="users()" formControlName="permissions_form"></pngx-permissions-form>
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</li>
|
</li>
|
||||||
@@ -477,29 +499,6 @@
|
|||||||
(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,7 +60,6 @@ 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'
|
||||||
@@ -138,7 +137,6 @@ 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
|
||||||
@@ -287,7 +285,6 @@ 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 })
|
||||||
@@ -778,7 +775,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, 'showSuccess')
|
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
patchSpy.mockImplementation((o) => of(doc))
|
patchSpy.mockImplementation((o) => of(doc))
|
||||||
component.save(true)
|
component.save(true)
|
||||||
expect(patchSpy).toHaveBeenCalled()
|
expect(patchSpy).toHaveBeenCalled()
|
||||||
@@ -793,7 +790,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, 'showSuccess')
|
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
patchSpy.mockImplementation((o) => of(doc))
|
patchSpy.mockImplementation((o) => of(doc))
|
||||||
component.save()
|
component.save()
|
||||||
expect(patchSpy).toHaveBeenCalled()
|
expect(patchSpy).toHaveBeenCalled()
|
||||||
@@ -839,7 +836,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, 'showSuccess')
|
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
patchSpy.mockImplementation(() =>
|
patchSpy.mockImplementation(() =>
|
||||||
throwError(() => new Error('failed to save'))
|
throwError(() => new Error('failed to save'))
|
||||||
)
|
)
|
||||||
@@ -960,16 +957,12 @@ describe('DocumentDetailComponent', () => {
|
|||||||
const modalSpy = jest.spyOn(modalService, 'open')
|
const modalSpy = jest.spyOn(modalService, 'open')
|
||||||
const deleteSpy = jest.spyOn(documentService, 'delete')
|
const deleteSpy = jest.spyOn(documentService, 'delete')
|
||||||
deleteSpy.mockReturnValue(of(true))
|
deleteSpy.mockReturnValue(of(true))
|
||||||
const notifySpy = jest
|
|
||||||
.spyOn(trashService, 'notifyMovedToTrash')
|
|
||||||
.mockImplementation(() => {})
|
|
||||||
component.delete()
|
component.delete()
|
||||||
expect(modalSpy).toHaveBeenCalled()
|
expect(modalSpy).toHaveBeenCalled()
|
||||||
const modalCloseSpy = jest.spyOn(openModal, 'close')
|
const modalCloseSpy = jest.spyOn(openModal, 'close')
|
||||||
openModal.componentInstance.confirmClicked.next()
|
openModal.componentInstance.confirmClicked.next()
|
||||||
expect(deleteSpy).toHaveBeenCalled()
|
expect(deleteSpy).toHaveBeenCalled()
|
||||||
expect(modalCloseSpy).toHaveBeenCalled()
|
expect(modalCloseSpy).toHaveBeenCalled()
|
||||||
expect(notifySpy).toHaveBeenCalledWith(expect.any(String), [doc.id])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should allow retry delete if error', () => {
|
it('should allow retry delete if error', () => {
|
||||||
|
|||||||
@@ -95,7 +95,6 @@ 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'
|
||||||
@@ -222,7 +221,6 @@ 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)
|
||||||
@@ -1351,7 +1349,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.showSuccess(
|
this.toastService.showInfo(
|
||||||
$localize`Document "${newValues.title}" saved successfully.`
|
$localize`Document "${newValues.title}" saved successfully.`
|
||||||
)
|
)
|
||||||
this.networkActive.set(false)
|
this.networkActive.set(false)
|
||||||
@@ -1379,7 +1377,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.showSuccess(
|
this.toastService.showInfo(
|
||||||
$localize`Document "${this.document().title}" saved successfully.`
|
$localize`Document "${this.document().title}" saved successfully.`
|
||||||
)
|
)
|
||||||
this.close()
|
this.close()
|
||||||
@@ -1492,10 +1490,6 @@ 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()
|
||||||
},
|
},
|
||||||
|
|||||||
+2
-5
@@ -33,9 +33,7 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
| 'updateVersionLabel'
|
| 'updateVersionLabel'
|
||||||
>
|
>
|
||||||
>
|
>
|
||||||
let toastService: jest.Mocked<
|
let toastService: jest.Mocked<Pick<ToastService, 'showError' | 'showInfo'>>
|
||||||
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'>>
|
||||||
@@ -57,7 +55,6 @@ 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),
|
||||||
@@ -419,6 +416,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.showSuccess).toHaveBeenCalled()
|
expect(toastService.showInfo).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
+1
-1
@@ -320,7 +320,7 @@ export class DocumentVersionDropdownComponent
|
|||||||
}
|
}
|
||||||
this.newVersionLabel = ''
|
this.newVersionLabel = ''
|
||||||
modal.close()
|
modal.close()
|
||||||
this.toastService.showSuccess(
|
this.toastService.showInfo(
|
||||||
$localize`Existing document added as a version.`
|
$localize`Existing document added as a version.`
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -87,16 +87,16 @@
|
|||||||
</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-2"></i-bs><ng-container i18n>Reprocess</ng-container>
|
<i-bs name="body-text" class="me-1"></i-bs><ng-container i18n>Reprocess</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<button ngbDropdownItem (click)="rotateSelected()" [disabled]="!userOwnsAll && !userCanEditAll">
|
<button ngbDropdownItem (click)="rotateSelected()" [disabled]="!userOwnsAll && !userCanEditAll">
|
||||||
<i-bs name="arrow-clockwise" class="me-2"></i-bs><ng-container i18n>Rotate</ng-container>
|
<i-bs name="arrow-clockwise" class="me-1"></i-bs><ng-container i18n>Rotate</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<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-2"></i-bs><ng-container i18n>Merge</ng-container>
|
<i-bs name="journals" class="me-1"></i-bs><ng-container i18n>Merge</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<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-2"></i-bs><ng-container i18n>Merge as versions</ng-container>
|
<i-bs name="journal-bookmark-fill" class="me-1"></i-bs><ng-container i18n>Merge as versions</ng-container>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -116,12 +116,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-2"></i-bs><ng-container i18n>Create a share link bundle</ng-container>
|
<i-bs name="link" class="me-1"></i-bs><ng-container i18n>Create a share link bundle</ng-container>
|
||||||
</button>
|
</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-2"></i-bs><ng-container i18n>Manage share link bundles</ng-container>
|
<i-bs name="list-ul" class="me-1"></i-bs><ng-container i18n>Manage share link bundles</ng-container>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@if (
|
@if (
|
||||||
@@ -133,7 +133,7 @@
|
|||||||
}
|
}
|
||||||
@if (emailEnabled) {
|
@if (emailEnabled) {
|
||||||
<button ngbDropdownItem (click)="emailSelected()" [disabled]="!canSendSelection">
|
<button ngbDropdownItem (click)="emailSelected()" [disabled]="!canSendSelection">
|
||||||
<i-bs name="envelope" class="me-2"></i-bs><ng-container i18n>Email</ng-container>
|
<i-bs name="envelope" class="me-1"></i-bs><ng-container i18n>Email</ng-container>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ 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'
|
||||||
@@ -75,7 +74,6 @@ 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
|
||||||
@@ -187,7 +185,6 @@ 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)
|
||||||
@@ -1167,14 +1164,10 @@ 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
|
||||||
@@ -1184,19 +1177,10 @@ 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', () => {
|
||||||
@@ -1432,7 +1416,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
const mergeAsVersionsSpy = jest
|
const mergeAsVersionsSpy = jest
|
||||||
.spyOn(documentService, 'mergeDocumentsAsVersions')
|
.spyOn(documentService, 'mergeDocumentsAsVersions')
|
||||||
.mockReturnValue(of(true))
|
.mockReturnValue(of(true))
|
||||||
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
|
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|
||||||
component.mergeSelectedAsVersions()
|
component.mergeSelectedAsVersions()
|
||||||
@@ -1448,9 +1432,7 @@ 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(toastSuccessSpy).toHaveBeenCalledWith(
|
expect(toastInfoSpy).toHaveBeenCalledWith('Documents merged as versions.')
|
||||||
'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', () => {
|
||||||
@@ -1476,7 +1458,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
jest
|
jest
|
||||||
.spyOn(documentService, 'mergeDocumentsAsVersions')
|
.spyOn(documentService, 'mergeDocumentsAsVersions')
|
||||||
.mockReturnValue(throwError(() => new Error('failed')))
|
.mockReturnValue(throwError(() => new Error('failed')))
|
||||||
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
|
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|
||||||
@@ -1485,7 +1467,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
modal.componentInstance.confirm()
|
modal.componentInstance.confirm()
|
||||||
|
|
||||||
expect(toastErrorSpy).toHaveBeenCalled()
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
expect(toastSuccessSpy).not.toHaveBeenCalled()
|
expect(toastInfoSpy).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', () => {
|
||||||
@@ -1864,7 +1846,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, 'showSuccess')
|
const toastServiceShowInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
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,15 +20,7 @@ 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 {
|
import { first, map, Observable, Subject, switchMap, takeUntil } from 'rxjs'
|
||||||
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'
|
||||||
@@ -57,7 +49,6 @@ 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'
|
||||||
@@ -108,7 +99,6 @@ 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)
|
||||||
@@ -316,7 +306,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.showSuccess(options.successMessage)
|
this.toastService.showInfo(options.successMessage)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: (error) => this.handleOperationError(modal, error),
|
error: (error) => this.handleOperationError(modal, error),
|
||||||
@@ -894,33 +884,19 @@ export class BulkEditorComponent
|
|||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
modal.componentInstance.buttonsEnabled.set(false)
|
modal.componentInstance.buttonsEnabled.set(false)
|
||||||
this.deleteSelected(modal)
|
this.executeDocumentAction(
|
||||||
|
modal,
|
||||||
|
this.documentService.deleteDocuments(this.getSelectionQuery())
|
||||||
|
)
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
this.deleteSelected(null)
|
this.executeDocumentAction(
|
||||||
|
null,
|
||||||
|
this.documentService.deleteDocuments(this.getSelectionQuery())
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private deleteSelected(modal: NgbModalRef) {
|
|
||||||
const documents = this.list.allSelected
|
|
||||||
? null
|
|
||||||
: Array.from(this.list.selected)
|
|
||||||
const count = this.getSelectionSize()
|
|
||||||
this.executeDocumentAction(
|
|
||||||
modal,
|
|
||||||
this.documentService
|
|
||||||
.deleteDocuments(this.getSelectionQuery())
|
|
||||||
.pipe(
|
|
||||||
tap(() =>
|
|
||||||
this.trashService.notifyMovedToTrash(
|
|
||||||
$localize`Moved ${count} document(s) to the trash`,
|
|
||||||
documents
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadSelected() {
|
downloadSelected() {
|
||||||
this.awaitingDownload.set(true)
|
this.awaitingDownload.set(true)
|
||||||
let downloadFileType: string =
|
let downloadFileType: string =
|
||||||
@@ -1089,7 +1065,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.showSuccess($localize`Custom fields updated.`)
|
this.toastService.showInfo($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, 'showSuccess')
|
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
|
|
||||||
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, 'showSuccess')
|
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
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.showSuccess(
|
this.toastService.showInfo(
|
||||||
$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.showSuccess(
|
this.toastService.showInfo(
|
||||||
$localize`View "${savedView.name}" created successfully.`
|
$localize`View "${savedView.name}" created successfully.`
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
+10
-46
@@ -484,22 +484,6 @@ describe('FilterEditorComponent', () => {
|
|||||||
expect(component.textFilter).toBeNull()
|
expect(component.textFilter).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should ingest title filter rules alongside a fulltext query with date created', () => {
|
|
||||||
component.filterRules = [
|
|
||||||
{
|
|
||||||
rule_type: FILTER_SIMPLE_TITLE,
|
|
||||||
value: 'foo',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
rule_type: FILTER_FULLTEXT_QUERY,
|
|
||||||
value: 'created:[-1 week to now]',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
expect(component.dateCreatedRelativeDate).toEqual(1) // RELATIVE_DATE_QUERYSTRINGS['-1 week to now']
|
|
||||||
expect(component.textFilter).toEqual('foo')
|
|
||||||
expect(component.textFilterTarget).toEqual('title') // TEXT_FILTER_TARGET_TITLE
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should ingest text filter rules for fulltext query that include date added', () => {
|
it('should ingest text filter rules for fulltext query that include date added', () => {
|
||||||
expect(component.dateAddedRelativeDate).toBeNull()
|
expect(component.dateAddedRelativeDate).toBeNull()
|
||||||
component.filterRules = [
|
component.filterRules = [
|
||||||
@@ -1861,7 +1845,7 @@ describe('FilterEditorComponent', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should keep text filtering separate from date created relative date', () => {
|
it('should carry over text filtering on date created with relative date', () => {
|
||||||
component.textFilter = 'foo'
|
component.textFilter = 'foo'
|
||||||
const dateCreatedDropdown = fixture.debugElement.queryAll(
|
const dateCreatedDropdown = fixture.debugElement.queryAll(
|
||||||
By.directive(DatesDropdownComponent)
|
By.directive(DatesDropdownComponent)
|
||||||
@@ -1871,18 +1855,14 @@ describe('FilterEditorComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
tick(400)
|
tick(400)
|
||||||
expect(component.filterRules).toEqual([
|
expect(component.filterRules).toEqual([
|
||||||
{
|
|
||||||
rule_type: FILTER_SIMPLE_TEXT,
|
|
||||||
value: 'foo',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
rule_type: FILTER_FULLTEXT_QUERY,
|
rule_type: FILTER_FULLTEXT_QUERY,
|
||||||
value: 'created:[-1 week to now]',
|
value: 'foo,created:[-1 week to now]',
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should keep text filtering separate from created and added relative dates', () => {
|
it('should carry over text filtering once with created and added relative dates', () => {
|
||||||
component.textFilter = 'foo'
|
component.textFilter = 'foo'
|
||||||
const datesDropdown = fixture.debugElement.query(
|
const datesDropdown = fixture.debugElement.query(
|
||||||
By.directive(DatesDropdownComponent)
|
By.directive(DatesDropdownComponent)
|
||||||
@@ -1893,18 +1873,14 @@ describe('FilterEditorComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
tick(400)
|
tick(400)
|
||||||
expect(component.filterRules).toEqual([
|
expect(component.filterRules).toEqual([
|
||||||
{
|
|
||||||
rule_type: FILTER_SIMPLE_TEXT,
|
|
||||||
value: 'foo',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
rule_type: FILTER_FULLTEXT_QUERY,
|
rule_type: FILTER_FULLTEXT_QUERY,
|
||||||
value: 'created:[-1 week to now],added:[-1 month to now]',
|
value: 'foo,created:[-1 week to now],added:[-1 month to now]',
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should keep legacy title filters as a title search when adding a created relative date', () => {
|
it('should convert legacy title filters into full text query when adding a created relative date', () => {
|
||||||
component.filterRules = [
|
component.filterRules = [
|
||||||
{
|
{
|
||||||
rule_type: FILTER_TITLE,
|
rule_type: FILTER_TITLE,
|
||||||
@@ -1919,18 +1895,14 @@ describe('FilterEditorComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
tick(400)
|
tick(400)
|
||||||
expect(component.filterRules).toEqual([
|
expect(component.filterRules).toEqual([
|
||||||
{
|
|
||||||
rule_type: FILTER_SIMPLE_TITLE,
|
|
||||||
value: 'foo',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
rule_type: FILTER_FULLTEXT_QUERY,
|
rule_type: FILTER_FULLTEXT_QUERY,
|
||||||
value: 'created:[-1 week to now]',
|
value: 'foo,created:[-1 week to now]',
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should keep simple title filters as a title search when adding a created relative date', () => {
|
it('should convert simple title filters into full text query when adding a created relative date', () => {
|
||||||
component.filterRules = [
|
component.filterRules = [
|
||||||
{
|
{
|
||||||
rule_type: FILTER_SIMPLE_TITLE,
|
rule_type: FILTER_SIMPLE_TITLE,
|
||||||
@@ -1945,13 +1917,9 @@ describe('FilterEditorComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
tick(400)
|
tick(400)
|
||||||
expect(component.filterRules).toEqual([
|
expect(component.filterRules).toEqual([
|
||||||
{
|
|
||||||
rule_type: FILTER_SIMPLE_TITLE,
|
|
||||||
value: 'foo',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
rule_type: FILTER_FULLTEXT_QUERY,
|
rule_type: FILTER_FULLTEXT_QUERY,
|
||||||
value: 'created:[-1 week to now]',
|
value: 'foo,created:[-1 week to now]',
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
@@ -2037,7 +2005,7 @@ describe('FilterEditorComponent', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should keep text filtering separate from date added relative date', () => {
|
it('should carry over text filtering on date added with relative date', () => {
|
||||||
component.textFilter = 'foo'
|
component.textFilter = 'foo'
|
||||||
const datesDropdown = fixture.debugElement.query(
|
const datesDropdown = fixture.debugElement.query(
|
||||||
By.directive(DatesDropdownComponent)
|
By.directive(DatesDropdownComponent)
|
||||||
@@ -2047,13 +2015,9 @@ describe('FilterEditorComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
tick(400)
|
tick(400)
|
||||||
expect(component.filterRules).toEqual([
|
expect(component.filterRules).toEqual([
|
||||||
{
|
|
||||||
rule_type: FILTER_SIMPLE_TEXT,
|
|
||||||
value: 'foo',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
rule_type: FILTER_FULLTEXT_QUERY,
|
rule_type: FILTER_FULLTEXT_QUERY,
|
||||||
value: 'added:[-1 week to now]',
|
value: 'foo,added:[-1 week to now]',
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1083,6 +1083,23 @@ export class FilterEditorComponent
|
|||||||
(fr) => fr.rule_type == FILTER_FULLTEXT_QUERY
|
(fr) => fr.rule_type == FILTER_FULLTEXT_QUERY
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// if had a title / content search and added a relative date we need to carry it over...
|
||||||
|
if (
|
||||||
|
!existingRule &&
|
||||||
|
this._textFilter?.length > 0 &&
|
||||||
|
(this.textFilterTarget == TEXT_FILTER_TARGET_TITLE_CONTENT ||
|
||||||
|
this.textFilterTarget == TEXT_FILTER_TARGET_TITLE)
|
||||||
|
) {
|
||||||
|
existingRule = filterRules.find(
|
||||||
|
(fr) =>
|
||||||
|
fr.rule_type == FILTER_TITLE_CONTENT ||
|
||||||
|
fr.rule_type == FILTER_SIMPLE_TEXT ||
|
||||||
|
fr.rule_type == FILTER_TITLE ||
|
||||||
|
fr.rule_type == FILTER_SIMPLE_TITLE
|
||||||
|
)
|
||||||
|
existingRule.rule_type = FILTER_FULLTEXT_QUERY
|
||||||
|
}
|
||||||
|
|
||||||
let queryArgs = existingRule?.value.split(',') ?? []
|
let queryArgs = existingRule?.value.split(',') ?? []
|
||||||
if (this.dateCreatedRelativeDate !== null) {
|
if (this.dateCreatedRelativeDate !== null) {
|
||||||
const rd = RELATIVE_DATE_QUERYSTRINGS.find(
|
const rd = RELATIVE_DATE_QUERYSTRINGS.find(
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ describe('FileDropComponent', () => {
|
|||||||
}
|
}
|
||||||
component.onDrop(dragEvent as DragEvent)
|
component.onDrop(dragEvent as DragEvent)
|
||||||
advanceTimers(3000)
|
advanceTimers(3000)
|
||||||
expect(toastSpy).not.toHaveBeenCalled()
|
expect(toastSpy).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).not.toHaveBeenCalled()
|
expect(toastSpy).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).not.toHaveBeenCalled()
|
expect(toastSpy).toHaveBeenCalled()
|
||||||
expect(uploadSpy).toHaveBeenCalled()
|
expect(uploadSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Loaded 100 of 308 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user