Compare commits

..
Author SHA1 Message Date
shamoon b5d44f6224 Remove angular platform-browser-dynamic 2026-07-10 01:00:47 -07:00
251 changed files with 63988 additions and 105042 deletions
+1 -2
View File
@@ -26,7 +26,6 @@ jobs:
check-name: 'Merge and Push Manifest' check-name: 'Merge and Push Manifest'
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
wait-interval: 60 wait-interval: 60
checks-discovery-timeout: 1800
build-release: build-release:
name: Build Release name: Build Release
needs: wait-for-docker needs: wait-for-docker
@@ -86,7 +85,7 @@ jobs:
# ---- Prepare Release ---- # ---- Prepare Release ----
- name: Generate requirements file - name: Generate requirements file
run: | run: |
uv export --quiet --no-dev --all-extras --emit-index-url --format requirements-txt --output-file requirements.txt uv export --quiet --no-dev --all-extras --format requirements-txt --output-file requirements.txt
- name: Compile messages - name: Compile messages
env: env:
PAPERLESS_SECRET_KEY: "ci-release-not-a-real-secret" PAPERLESS_SECRET_KEY: "ci-release-not-a-real-secret"
+4 -4
View File
@@ -38,7 +38,7 @@ repos:
- json - json
# See https://github.com/prettier/prettier/issues/15742 for the fork reason # See https://github.com/prettier/prettier/issues/15742 for the fork reason
- repo: https://github.com/rbubley/mirrors-prettier - repo: https://github.com/rbubley/mirrors-prettier
rev: 'v3.9.4' rev: 'v3.8.4'
hooks: hooks:
- id: prettier - id: prettier
types_or: types_or:
@@ -46,16 +46,16 @@ repos:
- ts - ts
- markdown - markdown
additional_dependencies: additional_dependencies:
- prettier@3.9.4 - prettier@3.8.3
- 'prettier-plugin-organize-imports@4.3.0' - 'prettier-plugin-organize-imports@4.3.0'
# Python hooks # Python hooks
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.20 rev: v0.15.17
hooks: hooks:
- id: ruff-check - id: ruff-check
- id: ruff-format - id: ruff-format
- repo: https://github.com/tox-dev/pyproject-fmt - repo: https://github.com/tox-dev/pyproject-fmt
rev: "v2.25.1" rev: "v2.24.1"
hooks: hooks:
- id: pyproject-fmt - id: pyproject-fmt
additional_dependencies: [tomli] additional_dependencies: [tomli]
+1 -5
View File
@@ -30,7 +30,7 @@ RUN set -eux \
# Purpose: Installs s6-overlay and rootfs # Purpose: Installs s6-overlay and rootfs
# Comments: # Comments:
# - Don't leave anything extra in here either # - Don't leave anything extra in here either
FROM ghcr.io/astral-sh/uv:0.11.28-python3.12-trixie-slim AS s6-overlay-base FROM ghcr.io/astral-sh/uv:0.11.19-python3.12-trixie-slim AS s6-overlay-base
WORKDIR /usr/src/s6 WORKDIR /usr/src/s6
@@ -238,10 +238,6 @@ RUN set -eux \
&& chown --from root:root --changes --recursive paperless:paperless /usr/src/paperless \ && chown --from root:root --changes --recursive paperless:paperless /usr/src/paperless \
&& echo "Making fontconfig cache writable for arbitrary container UIDs" \ && echo "Making fontconfig cache writable for arbitrary container UIDs" \
&& chmod 1777 /var/cache/fontconfig \ && chmod 1777 /var/cache/fontconfig \
&& echo "Making /run world-writable for rootless operation" \
&& chmod 1777 /run \
&& echo "Removing setuid from s6-overlay-suexec for rootless compat" \
&& chmod u-s /command/s6-overlay-suexec \
&& echo "Collecting static files" \ && echo "Collecting static files" \
&& PAPERLESS_SECRET_KEY=build-time-dummy s6-setuidgid paperless python3 manage.py collectstatic --clear --no-input --link \ && PAPERLESS_SECRET_KEY=build-time-dummy s6-setuidgid paperless python3 manage.py collectstatic --clear --no-input --link \
&& PAPERLESS_SECRET_KEY=build-time-dummy s6-setuidgid paperless python3 manage.py compilemessages \ && PAPERLESS_SECRET_KEY=build-time-dummy s6-setuidgid paperless python3 manage.py compilemessages \
+3 -3
View File
@@ -4,7 +4,7 @@
# correct networking for the tests # correct networking for the tests
services: services:
gotenberg: gotenberg:
image: docker.io/gotenberg/gotenberg:8.34 image: docker.io/gotenberg/gotenberg:8.33
hostname: gotenberg hostname: gotenberg
container_name: gotenberg container_name: gotenberg
network_mode: host network_mode: host
@@ -24,7 +24,7 @@ services:
network_mode: host network_mode: host
restart: unless-stopped restart: unless-stopped
greenmail: greenmail:
image: docker.io/greenmail/standalone:2.1.9 image: docker.io/greenmail/standalone:2.1.8
hostname: greenmail hostname: greenmail
container_name: greenmail container_name: greenmail
environment: environment:
@@ -35,7 +35,7 @@ services:
- "3143:3143" # IMAP - "3143:3143" # IMAP
restart: unless-stopped restart: unless-stopped
nginx: nginx:
image: docker.io/nginx:1.31.2-alpine image: docker.io/nginx:1.31.1-alpine
hostname: nginx hostname: nginx
container_name: nginx container_name: nginx
ports: ports:
@@ -72,7 +72,7 @@ services:
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000 PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
PAPERLESS_TIKA_ENDPOINT: http://tika:9998 PAPERLESS_TIKA_ENDPOINT: http://tika:9998
gotenberg: gotenberg:
image: docker.io/gotenberg/gotenberg:8.34 image: docker.io/gotenberg/gotenberg:8.33
restart: unless-stopped restart: unless-stopped
# The gotenberg chromium route is used to convert .eml files. We do not # The gotenberg chromium route is used to convert .eml files. We do not
# want to allow external content like tracking pixels or even javascript. # want to allow external content like tracking pixels or even javascript.
+1 -1
View File
@@ -56,7 +56,7 @@ services:
environment: environment:
PAPERLESS_REDIS: redis://broker:6379 PAPERLESS_REDIS: redis://broker:6379
PAPERLESS_DBHOST: db PAPERLESS_DBHOST: db
PAPERLESS_DBENGINE: postgresql PAPERLESS_DBENGINE: postgres
env_file: env_file:
- stack.env - stack.env
volumes: volumes:
@@ -67,7 +67,7 @@ services:
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000 PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
PAPERLESS_TIKA_ENDPOINT: http://tika:9998 PAPERLESS_TIKA_ENDPOINT: http://tika:9998
gotenberg: gotenberg:
image: docker.io/gotenberg/gotenberg:8.34 image: docker.io/gotenberg/gotenberg:8.33
restart: unless-stopped restart: unless-stopped
# The gotenberg chromium route is used to convert .eml files. We do not # The gotenberg chromium route is used to convert .eml files. We do not
# want to allow external content like tracking pixels or even javascript. # want to allow external content like tracking pixels or even javascript.
@@ -56,7 +56,7 @@ services:
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000 PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
PAPERLESS_TIKA_ENDPOINT: http://tika:9998 PAPERLESS_TIKA_ENDPOINT: http://tika:9998
gotenberg: gotenberg:
image: docker.io/gotenberg/gotenberg:8.34 image: docker.io/gotenberg/gotenberg:8.33
restart: unless-stopped restart: unless-stopped
# The gotenberg chromium route is used to convert .eml files. We do not # The gotenberg chromium route is used to convert .eml files. We do not
# want to allow external content like tracking pixels or even javascript. # want to allow external content like tracking pixels or even javascript.
-17
View File
@@ -151,23 +151,6 @@ the background.
### Bare Metal Route {#bare-metal-updating} ### Bare Metal Route {#bare-metal-updating}
!!! warning
Extracting a new release archive on top of an existing installation
(e.g. `tar -xf paperless-ngx-vX.Y.Z.tar.xz -C /opt/paperless`) only adds
and overwrites files -- it does not remove files that were deleted in
the new release. Leftover files from an old version, such as
superseded database migrations, can remain on disk and cause errors
like `NodeNotFoundError` during `manage.py migrate`.
Before unpacking a new release over an existing bare-metal
installation, remove the previous source tree first (everything
except your `media`, `data`, and `consume` directories and your
`paperless.conf`/`.env`), or unpack into a fresh directory and move
your persistent data and configuration over. Installations updated via
`git pull` on a clean checkout are not affected, since Git removes
files that no longer exist upstream.
After grabbing the new release and unpacking the contents, do the After grabbing the new release and unpacking the contents, do the
following: following:
+1 -985
View File
File diff suppressed because it is too large Load Diff
+16 -32
View File
@@ -522,36 +522,22 @@ do CORS calls. Set this to your public domain name.
fail2ban with log entries for failed authorization attempts. Value should be fail2ban with log entries for failed authorization attempts. Value should be
IP address(es). IP address(es).
By default, this setting also controls allauth's trusted proxy count, This setting also controls allauth's
which is set to the number of proxies listed here. Override that default [`ALLAUTH_TRUSTED_PROXY_COUNT`](https://docs.allauth.org/en/latest/account/configuration.html),
with [`PAPERLESS_ALLAUTH_TRUSTED_PROXY_COUNT`](#PAPERLESS_ALLAUTH_TRUSTED_PROXY_COUNT) which is set to the number of proxies listed here. Without this,
when the list length does not match the number of proxy hops. allauth cannot determine the client IP address for rate limiting when
running behind a reverse proxy, resulting in a `403 Forbidden` on login.
Defaults to empty string. Defaults to empty string.
#### [`PAPERLESS_ALLAUTH_TRUSTED_PROXY_COUNT=<integer>`](#PAPERLESS_ALLAUTH_TRUSTED_PROXY_COUNT) {#PAPERLESS_ALLAUTH_TRUSTED_PROXY_COUNT}
: Sets allauth's
[`ALLAUTH_TRUSTED_PROXY_COUNT`](https://docs.allauth.org/en/latest/common/rate_limits.html#configuration).
This is the number of trusted proxy **hops** represented in each
`X-Forwarded-For` header, not the number of IP addresses through which those
proxies may be reached. For example, a single dual-stack proxy is one hop even
when its IPv4 and IPv6 addresses are both listed in
[`PAPERLESS_TRUSTED_PROXIES`](#PAPERLESS_TRUSTED_PROXIES).
Only trust `X-Forwarded-For` when untrusted clients cannot connect directly
to Paperless-ngx.
Defaults to the number of entries in `PAPERLESS_TRUSTED_PROXIES`.
#### [`PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER=<header-name>`](#PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER) {#PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER} #### [`PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER=<header-name>`](#PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER) {#PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER}
: Sets allauth's : Sets allauth's
[`ALLAUTH_TRUSTED_CLIENT_IP_HEADER`](https://docs.allauth.org/en/latest/common/rate_limits.html#configuration). [`ALLAUTH_TRUSTED_CLIENT_IP_HEADER`](https://docs.allauth.org/en/latest/account/configuration.html).
Use this when your reverse proxy sets a dedicated header for the real Use this when your reverse proxy sets a dedicated header for the real
client IP instead of `X-Forwarded-For`, for example `X-Real-IP` (nginx) client IP instead of `X-Forwarded-For`, for example `X-Real-IP` (nginx)
or `CF-Connecting-IP` (Cloudflare). When set, this takes precedence over or `CF-Connecting-IP` (Cloudflare). When set, this takes precedence over
`PAPERLESS_ALLAUTH_TRUSTED_PROXY_COUNT`. [`PAPERLESS_TRUSTED_PROXIES`](#PAPERLESS_TRUSTED_PROXIES).
Defaults to none. Defaults to none.
@@ -1173,21 +1159,19 @@ still perform some basic text pre-processing before matching.
#### [`PAPERLESS_DATE_PARSER_LANGUAGES=<lang>`](#PAPERLESS_DATE_PARSER_LANGUAGES) {#PAPERLESS_DATE_PARSER_LANGUAGES} #### [`PAPERLESS_DATE_PARSER_LANGUAGES=<lang>`](#PAPERLESS_DATE_PARSER_LANGUAGES) {#PAPERLESS_DATE_PARSER_LANGUAGES}
: Specifies which language Paperless should use when parsing dates from documents. Specifies which language Paperless should use when parsing dates from documents.
: This should be a language code supported by the dateparser library, This should be a language code supported by the dateparser library,
for example: "en", or a combination such as "en+de". for example: "en", or a combination such as "en+de".
Locales are also supported (e.g., "en-AU"). Locales are also supported (e.g., "en-AU").
Multiple languages can be combined using "+", for example: "en+de" or "en-AU+de". Multiple languages can be combined using "+", for example: "en+de" or "en-AU+de".
For valid values, refer to the list of supported languages and locales in the [dateparser documentation](https://dateparser.readthedocs.io/en/latest/supported_locales.html). For valid values, refer to the list of supported languages and locales in the [dateparser documentation](https://dateparser.readthedocs.io/en/latest/supported_locales.html).
: Set this to match the languages in which most of your documents are written.
Set this to match the languages in which most of your documents are written.
If not set, Paperless will attempt to infer the language(s) from the OCR configuration (`PAPERLESS_OCR_LANGUAGE`). If not set, Paperless will attempt to infer the language(s) from the OCR configuration (`PAPERLESS_OCR_LANGUAGE`).
!!! note !!! note
This format differs from the `PAPERLESS_OCR_LANGUAGE` setting, which uses ISO 639-2 codes (3 letters, e.g., "eng+deu" for Tesseract OCR).
This format differs from the `PAPERLESS_OCR_LANGUAGE` setting, which uses ISO 639-2 codes (3 letters, e.g., "eng+deu" for Tesseract OCR).
#### [`PAPERLESS_EMAIL_TASK_CRON=<cron expression>`](#PAPERLESS_EMAIL_TASK_CRON) {#PAPERLESS_EMAIL_TASK_CRON} #### [`PAPERLESS_EMAIL_TASK_CRON=<cron expression>`](#PAPERLESS_EMAIL_TASK_CRON) {#PAPERLESS_EMAIL_TASK_CRON}
+5 -74
View File
@@ -133,7 +133,7 @@ PAPERLESS_DB_OPTIONS="sslmode=require,sslrootcert=/certs/ca.pem,pool.max_size=10
## OCR and Archive File Generation Settings ## OCR and Archive File Generation Settings
The settings that control OCR behaviour and archive file generation have been redesigned. The old settings that coupled these two concerns together are **removed** - old values are not silently honoured; a startup warning is logged if any removed variable is still set in your environment. The settings that control OCR behaviour and archive file generation have been redesigned. The old settings that coupled these two concerns together are **removed** old values are not silently honoured; a startup warning is logged if any removed variable is still set in your environment.
### Removed settings ### Removed settings
@@ -166,7 +166,7 @@ Remove any `PAPERLESS_OCR_SKIP_ARCHIVE_FILE` variable from your environment. If
# v2: skip OCR when text present, always archive # v2: skip OCR when text present, always archive
PAPERLESS_OCR_MODE=skip PAPERLESS_OCR_MODE=skip
# v3: equivalent (auto is the new default) # v3: equivalent (auto is the new default)
# No change needed - auto is the default # No change needed auto is the default
# v2: skip OCR when text present, skip archive too # v2: skip OCR when text present, skip archive too
PAPERLESS_OCR_MODE=skip_noarchive PAPERLESS_OCR_MODE=skip_noarchive
@@ -189,10 +189,10 @@ PAPERLESS_ARCHIVE_FILE_GENERATION=auto
If you use the **remote OCR parser** (Azure AI), note that it always produces a If you use the **remote OCR parser** (Azure AI), note that it always produces a
searchable PDF and stores it as the archive copy. `ARCHIVE_FILE_GENERATION=never` searchable PDF and stores it as the archive copy. `ARCHIVE_FILE_GENERATION=never`
has no effect for documents handled by the remote parser - the archive is produced has no effect for documents handled by the remote parser the archive is produced
unconditionally by the remote engine. unconditionally by the remote engine.
## Search Index (Whoosh -> Tantivy) # Search Index (Whoosh -> Tantivy)
The full-text search backend has been replaced with [Tantivy](https://github.com/quickwit-oss/tantivy). The full-text search backend has been replaced with [Tantivy](https://github.com/quickwit-oss/tantivy).
The index format is incompatible with Whoosh, so **the search index is automatically rebuilt from The index format is incompatible with Whoosh, so **the search index is automatically rebuilt from
@@ -324,74 +324,5 @@ option since v1.8.0.
Allauth changed how it determines the client IP address for login rate limiting. Users running Allauth changed how it determines the client IP address for login rate limiting. Users running
behind a reverse proxy may need to set behind a reverse proxy may need to set
[`PAPERLESS_TRUSTED_PROXIES`](configuration.md#PAPERLESS_TRUSTED_PROXIES), [`PAPERLESS_TRUSTED_PROXIES`](configuration.md#PAPERLESS_TRUSTED_PROXIES),
[`PAPERLESS_ALLAUTH_TRUSTED_PROXY_COUNT`](configuration.md#PAPERLESS_ALLAUTH_TRUSTED_PROXY_COUNT),
[`PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER`](configuration.md#PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER), [`PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER`](configuration.md#PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER),
or a combination of these settings to avoid `403 Forbidden` errors on login. or both, to avoid `403 Forbidden` errors on login.
The proxy count is the number of proxy hops in `X-Forwarded-For`, which may
differ from the number of configured proxy IP addresses.
## Minimum CPU Requirements (NumPy Baseline)
Starting with NumPy 2.4.0, official `manylinux` x86_64 wheels are compiled with a minimum
CPU baseline of `x86-64-v2`, which requires SSE3, SSSE3, SSE4.1, SSE4.2, POPCNT, and
CMPXCHG16B. This is a [deliberate upstream change](https://github.com/numpy/numpy/issues/27851)
citing that these instructions have been present in over 99.7% of CPUs since 2008
([Intel](<https://en.wikipedia.org/wiki/Nehalem_(microarchitecture)>)) or 2011
([AMD](<https://en.wikipedia.org/wiki/Bulldozer_(microarchitecture)>)).
NumPy is a dependency of the document classifier (via scikit-learn), so any CPU that
predates SSE4.2 support will crash with `SIGILL` (illegal instruction) when the classifier
is loaded or trained, regardless of whether AI features are enabled.
This differs from NumPy's optional SIMD dispatch (e.g. AVX2, AVX512), which is detected and
selected safely at runtime - the `x86-64-v2` requirement above is a hard floor baked into the
wheel, with no runtime fallback.
### Affected hardware
CPUs older than roughly 2008 (Intel) or 2011 (AMD) that lack SSE4.2 support. This is more
likely to affect low-power or embedded hardware - e.g. early Atom, Celeron, or pre-Bulldozer
AMD chips - than typical desktop or server hardware from the last decade.
Check for SSE4.2 support with:
```bash
grep -o -m1 sse4_2 /proc/cpuinfo
```
If this prints nothing, your CPU is affected.
### Symptoms
The Celery worker (and potentially the web server) repeatedly crashes and restarts with a
`SIGILL` error, typically visible in `dmesg`/`journalctl` as a `trap invalid opcode` inside
`_multiarray_umath...so`. Because the classifier is trained on a periodic schedule
(hourly, by default), affected instances see intermittent, hard-to-reproduce document
consumption failures whenever that scheduled task runs and takes down the worker process
mid-task.
### Action Required (for affected hardware only)
There is no way to make the classifier itself work on such CPUs - it requires an unofficial
NumPy build with `cpu-baseline=none`, which is not something we can ship. The practical
path forward is to stop the classifier from ever loading or training, which avoids
importing NumPy at all:
```bash
PAPERLESS_TRAIN_TASK_CRON=disable
```
This disables the periodic classifier training task (see
[`PAPERLESS_TRAIN_TASK_CRON`](configuration.md#PAPERLESS_TRAIN_TASK_CRON)). Automatic
matching based on the classifier (suggested correspondents, document types, tags, and
storage paths from trained rules) will no longer be available, but rule-based matching is
unaffected, and document consumption itself will no longer be at risk of crashing the
worker.
## Database Migrations
Some integer fields have been changed to smaller types to reduce database size. If you have any `MailRule` records with a `maximum_age` greater than 32767, they will be clamped to 32767 during the migration to avoid errors during migration.
### Action Required
No user action is required. The migration will automatically clamp any `MailRule.maximum_age` values greater than 32767 to 32767 during the migration process.
+3 -11
View File
@@ -142,7 +142,7 @@ a [superuser](usage.md#superusers) account.
It is not possible to run the container rootless if additional languages are specified via `PAPERLESS_OCR_LANGUAGES`. It is not possible to run the container rootless if additional languages are specified via `PAPERLESS_OCR_LANGUAGES`.
If you want to run Paperless as a rootless container, set `user:` in `docker-compose.yml` to the UID and GID of your host user (use `id -u` and `id -g` to find these values). The container process starts directly as that user with no internal privilege remapping. Any UID and GID is supported: If you want to run Paperless as a rootless container, set `user:` in `docker-compose.yml` to the UID and GID of your host user (use `id -u` and `id -g` to find these values). The container process starts directly as that user with no internal privilege remapping:
```yaml ```yaml
webserver: webserver:
@@ -157,8 +157,8 @@ Do not combine this with `USERMAP_UID` or `USERMAP_GID`, which are intended for
Some file systems, such as NFS network shares, don't support file system Some file systems, such as NFS network shares, don't support file system
notifications with `inotify`. When the consumption directory is on such a notifications with `inotify`. When the consumption directory is on such a
file system, Paperless-ngx will not pick up new files with the default file system, Paperless-ngx will not pick up new files with the default
configuration. Set [`PAPERLESS_CONSUMER_POLLING_INTERVAL`](configuration.md#PAPERLESS_CONSUMER_POLLING_INTERVAL) configuration. Use [`PAPERLESS_CONSUMER_POLLING`](configuration.md#PAPERLESS_CONSUMER_POLLING)
to a positive number to enable polling and disable native filesystem notifications. to enable polling and disable inotify. See [here](configuration.md#polling).
## Bare Metal Install {#bare_metal} ## Bare Metal Install {#bare_metal}
@@ -262,14 +262,6 @@ to a positive number to enable polling and disable native filesystem notificatio
and copy the contents to the home directory of the user you created and copy the contents to the home directory of the user you created
earlier (`/opt/paperless`). earlier (`/opt/paperless`).
!!! note
If you are updating an existing bare-metal installation rather than
installing for the first time, see the
[bare metal update instructions](administration.md#bare-metal-updating)
instead -- extracting a new release on top of an old one can leave
behind stale files from the previous version.
Optional: If you cloned the Git repository, you will need to Optional: If you cloned the Git repository, you will need to
compile the frontend yourself. See [here](development.md#front-end-development) compile the frontend yourself. See [here](development.md#front-end-development)
and use the `build` step, not `serve`. and use the `build` step, not `serve`.
+11 -12
View File
@@ -458,8 +458,8 @@ For related metadata such as tags, correspondents, document types, and storage p
### Password reset ### Password reset
In order to enable the password reset feature you will need to setup an SMTP backend, see In order to enable the password reset feature you will need to setup an SMTP backend, see
[`PAPERLESS_EMAIL_HOST`](configuration.md#PAPERLESS_EMAIL_HOST). You should also set [`PAPERLESS_EMAIL_HOST`](configuration.md#PAPERLESS_EMAIL_HOST). If your installation does not have
[`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) and / or its corresponding configuration settings. [`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) set, the reset link included in emails will use the server host.
### Two-factor authentication ### Two-factor authentication
@@ -833,10 +833,10 @@ contract you signed 8 years ago).
When you search paperless for a document, it tries to match this query When you search paperless for a document, it tries to match this query
against your documents. Paperless will look for matching documents by against your documents. Paperless will look for matching documents by
inspecting their content, title, correspondent, type, and tags. Paperless inspecting their content, title, correspondent, type, tags, notes, and
returns a scored list of results, so that documents matching your query custom field values. Paperless returns a scored list of results, so that
better will appear further up in the search results. Notes and custom field documents matching your query better will appear further up in the search
values can be searched using the advanced search syntax described below. results.
By default, paperless returns only documents which contain all words By default, paperless returns only documents which contain all words
typed in the search bar. A few things to know about how matching works: typed in the search bar. A few things to know about how matching works:
@@ -891,9 +891,9 @@ Supported date keywords: `today`, `yesterday`, `previous week`,
#### Searching custom fields #### Searching custom fields
Custom field names and values are included in the full-text index, but they Custom field values are included in the full-text index, so a plain search
are not searched by a plain, unqualified query. Use the advanced search syntax already matches documents whose custom field values contain your search terms.
to search by field name or value: To narrow by field name or value specifically:
``` ```
custom_fields.value:policy custom_fields.value:policy
@@ -921,9 +921,8 @@ custom_fields.name:"Contract Number" custom_fields.value:1312
#### Searching notes #### Searching notes
Notes are included in the full-text index, but they are not searched by a Notes content is included in full-text search automatically. To search
plain, unqualified query. Use the advanced search syntax to search by note by note author or content specifically:
author or content:
``` ```
notes.user:alice notes.user:alice
+6 -6
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "paperless-ngx" name = "paperless-ngx"
version = "3.0.3" version = "3.0.0"
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents" description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
@@ -16,7 +16,7 @@ classifiers = [
dependencies = [ dependencies = [
"azure-ai-documentintelligence>=1.0.2", "azure-ai-documentintelligence>=1.0.2",
"babel>=2.17", "babel>=2.17",
"bleach~=6.4.0", "bleach~=6.3.0",
"celery[redis]~=5.6.2", "celery[redis]~=5.6.2",
"channels~=4.2", "channels~=4.2",
"channels-redis~=4.2", "channels-redis~=4.2",
@@ -56,7 +56,7 @@ dependencies = [
"llama-index-embeddings-openai-like>=0.2.2", "llama-index-embeddings-openai-like>=0.2.2",
"llama-index-llms-ollama>=0.9.1", "llama-index-llms-ollama>=0.9.1",
"llama-index-llms-openai-like>=0.7.1", "llama-index-llms-openai-like>=0.7.1",
"nltk~=3.10.0", "nltk~=3.9.1",
"ocrmypdf~=17.4.2", "ocrmypdf~=17.4.2",
"openai>=2.32", "openai>=2.32",
"pathvalidate~=3.3.1", "pathvalidate~=3.3.1",
@@ -75,7 +75,7 @@ dependencies = [
"sqlite-vec==0.1.9", "sqlite-vec==0.1.9",
"tantivy~=0.26.0", "tantivy~=0.26.0",
"tika-client~=0.11.0", "tika-client~=0.11.0",
"torch~=2.13.0", "torch~=2.12.0",
"watchfiles>=1.1.1", "watchfiles>=1.1.1",
"whitenoise~=6.11", "whitenoise~=6.11",
"zxing-cpp~=3.0.0", "zxing-cpp~=3.0.0",
@@ -101,11 +101,11 @@ dev = [
{ include-group = "testing" }, { include-group = "testing" },
] ]
docs = [ docs = [
"zensical>=0.0.47", "zensical>=0.0.43",
] ]
lint = [ lint = [
"prek~=0.3.10", "prek~=0.3.10",
"ruff~=0.15.20", "ruff~=0.15.15",
] ]
testing = [ testing = [
"daphne", "daphne",
-10
View File
@@ -79,16 +79,6 @@
"glob": "{pdf.worker.min.mjs,pdf.min.mjs}", "glob": "{pdf.worker.min.mjs,pdf.min.mjs}",
"input": "node_modules/pdfjs-dist/legacy/build/", "input": "node_modules/pdfjs-dist/legacy/build/",
"output": "/assets/js/" "output": "/assets/js/"
},
{
"glob": "**/*",
"input": "node_modules/pdfjs-dist/wasm/",
"output": "/assets/wasm/"
},
{
"glob": "**/*",
"input": "node_modules/pdfjs-dist/iccs/",
"output": "/assets/iccs/"
} }
], ],
"styles": [ "styles": [
+335 -391
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "paperless-ngx-ui", "name": "paperless-ngx-ui",
"version": "3.0.3", "version": "3.0.0",
"scripts": { "scripts": {
"preinstall": "npx only-allow pnpm", "preinstall": "npx only-allow pnpm",
"ng": "ng", "ng": "ng",
+1856 -1256
View File
File diff suppressed because it is too large Load Diff
@@ -5,9 +5,8 @@
i18n-info> i18n-info>
<div class="input-group input-group-sm align-items-center"> <div class="input-group input-group-sm align-items-center">
<div class="input-group input-group-sm me-3"> <div class="input-group input-group-sm me-3">
<label class="input-group-text text-muted" for="logLineLimit" i18n>Show</label> <span class="input-group-text text-muted" i18n>Show</span>
<input <input
id="logLineLimit"
class="form-control" class="form-control"
type="number" type="number"
min="100" min="100"
@@ -18,7 +17,7 @@
<span class="input-group-text text-muted" i18n>lines</span> <span class="input-group-text text-muted" i18n>lines</span>
</div> </div>
<div class="form-check form-switch mt-1"> <div class="form-check form-switch mt-1">
<input id="autoRefreshSwitch" class="form-check-input" type="checkbox" role="switch" [ngModel]="autoRefreshEnabled()" (ngModelChange)="autoRefreshEnabled.set($event)"> <input class="form-check-input" type="checkbox" role="switch" [ngModel]="autoRefreshEnabled()" (ngModelChange)="autoRefreshEnabled.set($event)">
<label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label> <label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
</div> </div>
</div> </div>
@@ -100,7 +100,9 @@ export class LogsComponent
parsed.some((log, idx) => { parsed.some((log, idx) => {
const current = this.logs()[idx] const current = this.logs()[idx]
return ( return (
current?.message !== log.message || current?.level !== log.level !current ||
current.message !== log.message ||
current.level !== log.level
) )
}) })
if (hasChanges) { if (hasChanges) {
@@ -37,7 +37,7 @@
<form [formGroup]="settingsForm" (ngSubmit)="saveSettings()"> <form [formGroup]="settingsForm" (ngSubmit)="saveSettings()">
<ul ngbNav #nav="ngbNav" (navChange)="onNavChange($event)" [activeId]="activeNavID()" (activeIdChange)="activeNavID.set($event)" class="nav-tabs"> <ul ngbNav #nav="ngbNav" (navChange)="onNavChange($event)" [(activeId)]="activeNavID" class="nav-tabs">
<li [ngbNavItem]="SettingsNavIDs.General"> <li [ngbNavItem]="SettingsNavIDs.General">
<a ngbNavLink i18n>General</a> <a ngbNavLink i18n>General</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
@@ -150,10 +150,10 @@
<div class="row mb-3"> <div class="row mb-3">
<div class="col-md-3 col-form-label pt-0"> <div class="col-md-3 col-form-label pt-0">
<label for="searchLink" i18n>Full search links to</label> <span i18n>Full search links to</span>
</div> </div>
<div class="col mb-3"> <div class="col mb-3">
<select id="searchLink" class="form-select" formControlName="searchLink"> <select class="form-select" formControlName="searchLink">
<option [ngValue]="GlobalSearchType.TITLE_CONTENT" i18n>Title and content search</option> <option [ngValue]="GlobalSearchType.TITLE_CONTENT" i18n>Title and content search</option>
<option [ngValue]="GlobalSearchType.ADVANCED" i18n>Advanced search</option> <option [ngValue]="GlobalSearchType.ADVANCED" i18n>Advanced search</option>
</select> </select>
@@ -199,10 +199,10 @@
<h5 i18n>Documents</h5> <h5 i18n>Documents</h5>
<div class="row mb-3"> <div class="row mb-3">
<div class="col-md-3 col-form-label pt-0"> <div class="col-md-3 col-form-label pt-0">
<label for="documentListItemPerPage" i18n>Items per page</label> <span i18n>Items per page</span>
</div> </div>
<div class="col"> <div class="col">
<select id="documentListItemPerPage" class="form-select" formControlName="documentListItemPerPage"> <select class="form-select" formControlName="documentListItemPerPage">
<option [ngValue]="10">10</option> <option [ngValue]="10">10</option>
<option [ngValue]="25">25</option> <option [ngValue]="25">25</option>
<option [ngValue]="50">50</option> <option [ngValue]="50">50</option>
@@ -274,10 +274,10 @@
<h5 class="mt-3" i18n>PDF Editor</h5> <h5 class="mt-3" i18n>PDF Editor</h5>
<div class="row"> <div class="row">
<div class="col-md-3 col-form-label pt-0"> <div class="col-md-3 col-form-label pt-0">
<label for="pdfEditorDefaultEditMode" i18n>Default editing mode</label> <span i18n>Default editing mode</span>
</div> </div>
<div class="col"> <div class="col">
<select id="pdfEditorDefaultEditMode" class="form-select" formControlName="pdfEditorDefaultEditMode"> <select class="form-select" formControlName="pdfEditorDefaultEditMode">
<option [ngValue]="PdfEditorEditMode.Create" i18n>Create new document(s)</option> <option [ngValue]="PdfEditorEditMode.Create" i18n>Create new document(s)</option>
<option [ngValue]="PdfEditorEditMode.Update" i18n>Add document version</option> <option [ngValue]="PdfEditorEditMode.Update" i18n>Add document version</option>
</select> </select>
@@ -304,7 +304,7 @@
<div class="row mb-3"> <div class="row mb-3">
<div class="col"> <div class="col">
<p i18n> <p i18n>
Settings apply to this user account for objects (Tags, Mail Rules, etc.) created via the web UI. These settings do not apply to documents. Settings apply to this user account for objects (Tags, Mail Rules, etc. but not documents) created via the web UI.
</p> </p>
</div> </div>
</div> </div>
@@ -242,7 +242,7 @@ describe('SettingsComponent', () => {
activatedRoute.snapshot.fragment = '#notifications' activatedRoute.snapshot.fragment = '#notifications'
const scrollSpy = jest.spyOn(viewportScroller, 'scrollToAnchor') const scrollSpy = jest.spyOn(viewportScroller, 'scrollToAnchor')
component.ngOnInit() component.ngOnInit()
expect(component.activeNavID()).toEqual(4) // Notifications expect(component.activeNavID).toEqual(4) // Notifications
component.ngAfterViewInit() component.ngAfterViewInit()
expect(scrollSpy).toHaveBeenCalledWith('#notifications') expect(scrollSpy).toHaveBeenCalledWith('#notifications')
}) })
@@ -399,17 +399,17 @@ describe('SettingsComponent', () => {
completeSetup() completeSetup()
const field = 'storage_path' const field = 'storage_path'
expect( expect(
component.settingsForm.get('documentDetailsHiddenFields').value component.settingsForm.get('documentDetailsHiddenFields').value.length
).toHaveLength(0) ).toEqual(0)
component.toggleDocumentDetailField(field, false) component.toggleDocumentDetailField(field, false)
expect( expect(
component.settingsForm.get('documentDetailsHiddenFields').value component.settingsForm.get('documentDetailsHiddenFields').value.length
).toHaveLength(1) ).toEqual(1)
expect(component.isDocumentDetailFieldShown(field)).toBeFalsy() expect(component.isDocumentDetailFieldShown(field)).toBeFalsy()
component.toggleDocumentDetailField(field, true) component.toggleDocumentDetailField(field, true)
expect( expect(
component.settingsForm.get('documentDetailsHiddenFields').value component.settingsForm.get('documentDetailsHiddenFields').value.length
).toHaveLength(0) ).toEqual(0)
expect(component.isDocumentDetailFieldShown(field)).toBeTruthy() expect(component.isDocumentDetailFieldShown(field)).toBeTruthy()
}) })
}) })
@@ -142,7 +142,7 @@ export class SettingsComponent
private systemStatusService = inject(SystemStatusService) private systemStatusService = inject(SystemStatusService)
private savedViewsService = inject(SavedViewService) private savedViewsService = inject(SavedViewService)
readonly activeNavID = signal<number>(undefined) activeNavID: number
settingsForm = new FormGroup({ settingsForm = new FormGroup({
bulkEditConfirmationDialogs: new FormControl(null), bulkEditConfirmationDialogs: new FormControl(null),
@@ -283,7 +283,7 @@ export class SettingsComponent
(navID) => navID.toLowerCase() == section (navID) => navID.toLowerCase() == section
) )
if (navIDKey) { if (navIDKey) {
this.activeNavID.set(SettingsNavIDs[navIDKey]) this.activeNavID = SettingsNavIDs[navIDKey]
} }
} }
}) })
@@ -386,7 +386,7 @@ export class SettingsComponent
.navigate(['settings', foundNavIDkey.toLowerCase()]) .navigate(['settings', foundNavIDkey.toLowerCase()])
.then((navigated) => { .then((navigated) => {
if (!navigated && this.isDirty) { if (!navigated && this.isDirty) {
this.activeNavID.set(navChangeEvent.activeId) this.activeNavID = navChangeEvent.activeId
} else if (navigated && this.isDirty) { } else if (navigated && this.isDirty) {
this.initialize() this.initialize()
} }
@@ -15,7 +15,7 @@
<i-bs name="check2-all" class="me-1"></i-bs><ng-container i18n>Dismiss all</ng-container> <i-bs name="check2-all" class="me-1"></i-bs><ng-container i18n>Dismiss all</ng-container>
</button> </button>
<div class="form-check form-switch mb-0 ms-2"> <div class="form-check form-switch mb-0 ms-2">
<input id="autoRefreshSwitch" class="form-check-input" type="checkbox" role="switch" [(ngModel)]="autoRefreshEnabled"> <input class="form-check-input" type="checkbox" role="switch" [(ngModel)]="autoRefreshEnabled">
<label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label> <label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
</div> </div>
</div> </div>
@@ -93,8 +93,7 @@
<i-bs width="1em" height="1em" name="x"></i-bs> <i-bs width="1em" height="1em" name="x"></i-bs>
</button> </button>
} }
<label class="visually-hidden" for="taskSearch" i18n>Search tasks</label> <input #filterInput class="form-control form-control-sm" type="text"
<input #filterInput id="taskSearch" class="form-control form-control-sm" type="text"
(keyup)="filterInputKeyup($event)" (keyup)="filterInputKeyup($event)"
[(ngModel)]="filterText"> [(ngModel)]="filterText">
</div> </div>
@@ -212,7 +211,7 @@
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs> <i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
</button> </button>
</td> </td>
<td class="actions-col"> <td scope="row" class="actions-col">
<div class="btn-group" role="group"> <div class="btn-group" role="group">
<button class="btn btn-sm btn-outline-secondary" (click)="dismissTask(task); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }"> <button class="btn btn-sm btn-outline-secondary" (click)="dismissTask(task); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }">
<i-bs name="check" class="me-1"></i-bs><ng-container i18n>Dismiss</ng-container> <i-bs name="check" class="me-1"></i-bs><ng-container i18n>Dismiss</ng-container>
@@ -150,8 +150,7 @@
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
popoverClass="popover-slim"> popoverClass="popover-slim">
<i-bs class="me-2" name="file-text"></i-bs><span>{{d.title | documentTitle}}</span> <i-bs class="me-2" name="file-text"></i-bs><span>{{d.title | documentTitle}}</span>
<span class="close flex-column justify-content-center" <span class="close flex-column justify-content-center" (click)="closeDocument(d); $event.preventDefault()">
(click)="$event.preventDefault(); $event.stopPropagation(); closeDocument(d)">
<i-bs name="x"></i-bs> <i-bs name="x"></i-bs>
</span> </span>
</a> </a>
@@ -280,52 +280,10 @@ main {
} }
} }
@media screen and (max-width: 575.98px) { @media screen and (min-width: 376px) and (max-width: 768px) {
.navbar {
display: grid;
grid-template-columns: 1fr auto 1fr;
grid-template-areas:
"toggler brand actions"
"search search search";
}
.navbar-toggler { .navbar-toggler {
grid-area: toggler; // compensate for 2 buttons on the right
text-align: left; margin-right: 45px;
}
.navbar-brand {
grid-area: brand;
min-width: 0;
max-width: 100%;
justify-self: center;
overflow: hidden;
> div,
.custom-title {
min-width: 0;
max-width: 100%;
overflow: hidden;
}
.title,
.byline {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.search-container {
grid-area: search;
width: 100%;
}
.navbar > ul {
grid-area: actions;
justify-self: end;
flex-wrap: nowrap;
} }
} }
@@ -335,6 +293,13 @@ main {
} }
} }
@media screen and (max-width: 345px) {
.custom-title {
max-width: 110px;
overflow: hidden;
}
}
:host ::ng-deep .dropdown.show .dropdown-toggle, :host ::ng-deep .dropdown.show .dropdown-toggle,
:host ::ng-deep .dropdown-toggle:hover { :host ::ng-deep .dropdown-toggle:hover {
opacity: 0.7; opacity: 0.7;
@@ -6,17 +6,17 @@
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown"> <div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown">
<div class="chat-container bg-light p-2"> <div class="chat-container bg-light p-2">
<div class="chat-messages font-monospace small"> <div class="chat-messages font-monospace small">
@for (message of messages(); track message) { @for (message of messages; track message) {
<div class="message d-flex flex-row small" [class.justify-content-end]="message.role === 'user'"> <div class="message d-flex flex-row small" [class.justify-content-end]="message.role === 'user'">
<div class="p-2 m-2" [class.bg-body]="message.role === 'user'"> <div class="p-2 m-2" [class.bg-body]="message.role === 'user'">
<span class="text-break"> <span>
{{ message.content }} {{ message.content }}
@if (message.isStreaming) { <span class="blinking-cursor">|</span> } @if (message.isStreaming) { <span class="blinking-cursor">|</span> }
</span> </span>
@if (message.role === 'assistant' && message.references?.length) { @if (message.role === 'assistant' && message.references?.length) {
<div class="chat-references list-group mt-3"> <div class="chat-references list-group mt-3">
@for (reference of message.references; track reference.id) { @for (reference of message.references; track reference.id) {
<a class="list-group-item list-group-item-action d-flex text-primary text-break" [routerLink]="['/documents', reference.id]"> <a class="list-group-item list-group-item-action text-primary" [routerLink]="['/documents', reference.id]">
<i-bs width="0.9em" height="0.9em" name="file-text" class="me-1"></i-bs><span>{{ reference.title }}</span> <i-bs width="0.9em" height="0.9em" name="file-text" class="me-1"></i-bs><span>{{ reference.title }}</span>
</a> </a>
} }
@@ -29,19 +29,16 @@
</div> </div>
<form class="chat-input"> <form class="chat-input">
<label class="visually-hidden" for="chatInput" i18n>Message</label>
<div class="input-group"> <div class="input-group">
<input <input
#chatInput #chatInput
id="chatInput"
class="form-control form-control-sm" 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)"
(keydown)="searchInputKeyDown($event)" (keydown)="searchInputKeyDown($event)"
/> />
<button class="btn btn-sm btn-secondary" type="button" (click)="sendMessage()" [disabled]="loading()">Send</button> <button class="btn btn-sm btn-secondary" type="button" (click)="sendMessage()" [disabled]="loading">Send</button>
</div> </div>
</form> </form>
</div> </div>
@@ -5,7 +5,6 @@
.chat-messages { .chat-messages {
max-height: 350px; max-height: 350px;
overflow-y: auto; overflow-y: auto;
white-space: pre-wrap;
} }
.chat-references { .chat-references {
@@ -56,53 +56,44 @@ describe('ChatComponent', () => {
it('should update documentId on initialization', () => { it('should update documentId on initialization', () => {
jest.spyOn(router, 'url', 'get').mockReturnValue('/documents/123') jest.spyOn(router, 'url', 'get').mockReturnValue('/documents/123')
component.ngOnInit() component.ngOnInit()
expect(component.documentId()).toBe(123) expect(component.documentId).toBe(123)
}) })
it('should update documentId on navigation', () => { it('should update documentId on navigation', () => {
component.ngOnInit() component.ngOnInit()
routerEvents$.next(new NavigationEnd(1, '/documents/456', '/documents/456')) routerEvents$.next(new NavigationEnd(1, '/documents/456', '/documents/456'))
expect(component.documentId()).toBe(456) expect(component.documentId).toBe(456)
}) })
it('should return correct placeholder based on documentId', () => { it('should return correct placeholder based on documentId', () => {
component.documentId.set(123) component.documentId = 123
expect(component.placeholder).toBe('Ask a question about this document...') expect(component.placeholder).toBe('Ask a question about this document...')
component.documentId.set(undefined) component.documentId = undefined
expect(component.placeholder).toBe('Ask a question about a document...') expect(component.placeholder).toBe('Ask a question about a document...')
}) })
it('should send a message and render the streaming response', async () => { it('should send a message and handle streaming response', () => {
component.input.set('Hello') component.input = 'Hello'
component.sendMessage() component.sendMessage()
expect(component.messages()).toHaveLength(2) expect(component.messages.length).toBe(2)
expect(component.messages()[0].content).toBe('Hello') expect(component.messages[0].content).toBe('Hello')
expect(component.loading()).toBe(true) expect(component.loading).toBe(true)
mockStream$.next('Hi') mockStream$.next('Hi')
expect(component.messages()[1].content).toBe('H') expect(component.messages[1].content).toBe('H')
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() jest.advanceTimersByTime(1000)
await fixture.whenStable() expect(component.messages[1].content).toBe('Hi there')
expect(component.messages()[1].content).toBe('Hi there')
expect(
fixture.nativeElement.querySelector('.chat-messages').textContent
).toContain('Hi there')
mockStream$.complete() mockStream$.complete()
await jest.runAllTimersAsync() expect(component.loading).toBe(false)
await fixture.whenStable() expect(component.messages[1].isStreaming).toBe(false)
expect(component.loading()).toBe(false)
expect(component.messages()[1].isStreaming).toBe(false)
expect(fixture.nativeElement.querySelector('#chatInput').disabled).toBe(
false
)
}) })
it('should parse references from the metadata trailer without showing it', () => { it('should parse references from the metadata trailer without showing it', () => {
component.input.set('Hello') component.input = 'Hello'
component.sendMessage() component.sendMessage()
mockStream$.next( mockStream$.next(
@@ -110,14 +101,14 @@ describe('ChatComponent', () => {
) )
jest.advanceTimersByTime(1000) jest.advanceTimersByTime(1000)
expect(component.messages()[1].content).toBe('Hi there') expect(component.messages[1].content).toBe('Hi there')
expect(component.messages()[1].references).toEqual([ expect(component.messages[1].references).toEqual([
{ id: 42, title: 'Bread Recipe' }, { id: 42, title: 'Bread Recipe' },
]) ])
}) })
it('should render document reference links under assistant messages', () => { it('should render document reference links under assistant messages', () => {
component.input.set('Hello') component.input = 'Hello'
component.sendMessage() component.sendMessage()
mockStream$.next( mockStream$.next(
@@ -132,12 +123,12 @@ describe('ChatComponent', () => {
}) })
it('should remove delimiter fragments that were already streamed', () => { it('should remove delimiter fragments that were already streamed', () => {
component.input.set('Hello') component.input = 'Hello'
component.sendMessage() component.sendMessage()
mockStream$.next(`Hi there${CHAT_METADATA_DELIMITER.slice(0, 8)}`) mockStream$.next(`Hi there${CHAT_METADATA_DELIMITER.slice(0, 8)}`)
jest.advanceTimersByTime(1000) jest.advanceTimersByTime(1000)
expect(component.messages()[1].content).toBe( expect(component.messages[1].content).toBe(
`Hi there${CHAT_METADATA_DELIMITER.slice(0, 8)}` `Hi there${CHAT_METADATA_DELIMITER.slice(0, 8)}`
) )
@@ -146,28 +137,28 @@ describe('ChatComponent', () => {
) )
jest.advanceTimersByTime(1000) jest.advanceTimersByTime(1000)
expect(component.messages()[1].content).toBe('Hi there') expect(component.messages[1].content).toBe('Hi there')
expect(component.messages()[1].references).toEqual([ expect(component.messages[1].references).toEqual([
{ id: 42, title: 'Bread Recipe' }, { id: 42, title: 'Bread Recipe' },
]) ])
}) })
it('should handle errors during streaming', () => { it('should handle errors during streaming', () => {
component.input.set('Hello') component.input = 'Hello'
component.sendMessage() component.sendMessage()
mockStream$.error('Error') mockStream$.error('Error')
expect(component.messages()[1].content).toContain( expect(component.messages[1].content).toContain(
'⚠️ Error receiving response.' '⚠️ Error receiving response.'
) )
expect(component.loading()).toBe(false) expect(component.loading).toBe(false)
}) })
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
component.enqueueTypewriter('Hello', message as any) component.enqueueTypewriter('Hello', message as any)
expect(component['typewriterBuffer']).toHaveLength(4) expect(component['typewriterBuffer'].length).toBe(4)
}) })
it('should scroll to bottom after sending a message', () => { it('should scroll to bottom after sending a message', () => {
@@ -175,7 +166,7 @@ describe('ChatComponent', () => {
ChatComponent.prototype as any, ChatComponent.prototype as any,
'scrollToBottom' 'scrollToBottom'
) )
component.input.set('Test') component.input = 'Test'
component.sendMessage() component.sendMessage()
expect(scrollSpy).toHaveBeenCalled() expect(scrollSpy).toHaveBeenCalled()
}) })
@@ -1,11 +1,4 @@
import { import { Component, ElementRef, inject, OnInit, ViewChild } from '@angular/core'
Component,
ElementRef,
inject,
OnInit,
signal,
ViewChild,
} from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' 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'
@@ -30,10 +23,10 @@ import {
styleUrl: './chat.component.scss', styleUrl: './chat.component.scss',
}) })
export class ChatComponent implements OnInit { export class ChatComponent implements OnInit {
readonly messages = signal<ChatMessage[]>([]) public messages: ChatMessage[] = []
readonly loading = signal(false) public loading = false
readonly input = signal('') public input: string = ''
readonly documentId = signal<number>(undefined) public documentId!: number
private chatService: ChatService = inject(ChatService) private chatService: ChatService = inject(ChatService)
private router: Router = inject(Router) private router: Router = inject(Router)
@@ -45,7 +38,7 @@ export class ChatComponent implements OnInit {
private typewriterActive = false private typewriterActive = false
public get placeholder(): string { public get placeholder(): string {
return this.documentId() return this.documentId
? $localize`Ask a question about this document...` ? $localize`Ask a question about this document...`
: $localize`Ask a question about a document...` : $localize`Ask a question about a document...`
} }
@@ -64,14 +57,14 @@ export class ChatComponent implements OnInit {
private updateDocumentId(url: string): void { private updateDocumentId(url: string): void {
const docIdRe = url.match(/^\/documents\/(\d+)/) const docIdRe = url.match(/^\/documents\/(\d+)/)
this.documentId.set(docIdRe ? +docIdRe[1] : undefined) this.documentId = docIdRe ? +docIdRe[1] : undefined
} }
sendMessage(): void { sendMessage(): void {
if (!this.input().trim()) return if (!this.input.trim()) return
const userMessage: ChatMessage = { role: 'user', content: this.input() } const userMessage: ChatMessage = { role: 'user', content: this.input }
this.messages.update((messages) => [...messages, userMessage]) this.messages.push(userMessage)
this.scrollToBottom() this.scrollToBottom()
const assistantMessage: ChatMessage = { const assistantMessage: ChatMessage = {
@@ -79,12 +72,12 @@ export class ChatComponent implements OnInit {
content: '', content: '',
isStreaming: true, isStreaming: true,
} }
this.messages.update((messages) => [...messages, assistantMessage]) this.messages.push(assistantMessage)
this.loading.set(true) this.loading = true
let lastVisibleContent = '' let lastVisibleContent = ''
this.chatService.streamChat(this.documentId(), this.input()).subscribe({ this.chatService.streamChat(this.documentId, this.input).subscribe({
next: (chunk) => { next: (chunk) => {
const nextResponse = parseChatResponse(chunk) const nextResponse = parseChatResponse(chunk)
@@ -100,30 +93,26 @@ export class ChatComponent implements OnInit {
} }
assistantMessage.references = nextResponse.references assistantMessage.references = nextResponse.references
this.notifyMessagesChanged()
}, },
error: () => { error: () => {
assistantMessage.content += '\n\n⚠️ Error receiving response.' assistantMessage.content += '\n\n⚠️ Error receiving response.'
assistantMessage.isStreaming = false assistantMessage.isStreaming = false
this.notifyMessagesChanged() this.loading = false
this.loading.set(false)
}, },
complete: () => { complete: () => {
assistantMessage.isStreaming = false assistantMessage.isStreaming = false
this.notifyMessagesChanged() this.loading = false
this.loading.set(false)
this.scrollToBottom() this.scrollToBottom()
}, },
}) })
this.input.set('') this.input = ''
} }
private resetTypewriter(message: ChatMessage, content: string): void { private resetTypewriter(message: ChatMessage, content: string): void {
this.typewriterBuffer = [] this.typewriterBuffer = []
this.typewriterActive = false this.typewriterActive = false
message.content = content message.content = content
this.notifyMessagesChanged()
this.scrollToBottom() this.scrollToBottom()
} }
@@ -146,16 +135,11 @@ export class ChatComponent implements OnInit {
const nextChar = this.typewriterBuffer.shift() const nextChar = this.typewriterBuffer.shift()
message.content += nextChar message.content += nextChar
this.notifyMessagesChanged()
this.scrollToBottom() this.scrollToBottom()
setTimeout(() => this.playTypewriter(message), 10) // 10ms per character setTimeout(() => this.playTypewriter(message), 10) // 10ms per character
} }
private notifyMessagesChanged(): void {
this.messages.update((messages) => [...messages])
}
private scrollToBottom(): void { private scrollToBottom(): void {
setTimeout(() => { setTimeout(() => {
this.scrollAnchor?.nativeElement?.scrollIntoView({ behavior: 'smooth' }) this.scrollAnchor?.nativeElement?.scrollIntoView({ behavior: 'smooth' })
@@ -17,6 +17,6 @@
<ng-template #popoverContent> <ng-template #popoverContent>
<div> <div>
{{confirmMessage}}&nbsp;<button class="btn btn-link btn-sm text-danger p-0 m-0 lh-1" type="button" (click)="onConfirm($event)" i18n>Yes</button> {{confirmMessage}}&nbsp;<button class="btn btn-link btn-sm text-danger p-0 m-0 lh-1" type="button" (click)="onConfirm($event)">Yes</button>
</div> </div>
</ng-template> </ng-template>
@@ -1,12 +1,5 @@
import { CurrencyPipe, getLocaleCurrencyCode, SlicePipe } from '@angular/common' import { CurrencyPipe, getLocaleCurrencyCode, SlicePipe } from '@angular/common'
import { import { Component, inject, Input, LOCALE_ID, OnInit } from '@angular/core'
ChangeDetectorRef,
Component,
inject,
Input,
LOCALE_ID,
OnInit,
} from '@angular/core'
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap' import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
import { takeUntil } from 'rxjs' import { takeUntil } from 'rxjs'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field' import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
@@ -29,7 +22,6 @@ export class CustomFieldDisplayComponent
{ {
private customFieldService = inject(CustomFieldsService) private customFieldService = inject(CustomFieldsService)
private documentService = inject(DocumentService) private documentService = inject(DocumentService)
private changeDetector = inject(ChangeDetectorRef)
CustomFieldDataType = CustomFieldDataType CustomFieldDataType = CustomFieldDataType
@@ -82,7 +74,6 @@ export class CustomFieldDisplayComponent
this.customFieldService.listAll().subscribe((r) => { this.customFieldService.listAll().subscribe((r) => {
this.customFields = r.results this.customFields = r.results
this.init() this.init()
this.changeDetector.markForCheck()
}) })
} }
@@ -120,7 +111,6 @@ export class CustomFieldDisplayComponent
this.docLinkDocuments = this.value this.docLinkDocuments = this.value
.map((id) => result.results.find((d) => d.id === id)) .map((id) => result.results.find((d) => d.id === id))
.filter((d) => d) .filter((d) => d)
this.changeDetector.markForCheck()
}) })
} }
@@ -95,8 +95,8 @@ describe('CustomFieldsDropdownComponent', () => {
it('should support update unused fields', () => { it('should support update unused fields', () => {
component.existingFields = [{ field: fields[0].id } as any] component.existingFields = [{ field: fields[0].id } as any]
component['updateUnusedFields']() component['updateUnusedFields']()
expect(component['unusedFields']().length).toEqual(1) expect(component['unusedFields'].length).toEqual(1)
expect(component['unusedFields']()[0].name).toEqual('Field 2') expect(component['unusedFields'][0].name).toEqual('Field 2')
}) })
it('should support getting data type label', () => { it('should support getting data type label', () => {
@@ -8,7 +8,6 @@ import {
ViewChild, ViewChild,
ViewChildren, ViewChildren,
inject, inject,
signal,
} from '@angular/core' } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap' import { NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap'
@@ -66,11 +65,11 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio
@ViewChildren('button') buttons: QueryList<ElementRef> @ViewChildren('button') buttons: QueryList<ElementRef>
private customFields: CustomField[] = [] private customFields: CustomField[] = []
private readonly unusedFields = signal<CustomField[]>([]) private unusedFields: CustomField[] = []
private keyboardIndex: number private keyboardIndex: number
public get filteredFields(): CustomField[] { public get filteredFields(): CustomField[] {
return this.unusedFields().filter( return this.unusedFields.filter(
(f) => !this.filterText || matchesSearchText(f.name, this.filterText) (f) => !this.filterText || matchesSearchText(f.name, this.filterText)
) )
} }
@@ -100,10 +99,8 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio
} }
private updateUnusedFields() { private updateUnusedFields() {
this.unusedFields.set( this.unusedFields = this.customFields.filter(
this.customFields.filter( (f) => !this.existingFields?.find((e) => e.field === f.id)
(f) => !this.existingFields?.find((e) => e.field === f.id)
)
) )
} }
@@ -77,7 +77,7 @@
<div class="input-group input-group-sm"> <div class="input-group input-group-sm">
<ng-select #fieldSelects <ng-select #fieldSelects
class="paperless-input-select" class="paperless-input-select"
[items]="customFields()" [items]="customFields"
[(ngModel)]="atom.field" [(ngModel)]="atom.field"
[disabled]="disabled" [disabled]="disabled"
bindLabel="name" bindLabel="name"
@@ -79,7 +79,7 @@ describe('CustomFieldsQueryDropdownComponent', () => {
}) })
it('should initialize custom fields on creation', () => { it('should initialize custom fields on creation', () => {
expect(component.customFields()).toEqual(customFields) expect(component.customFields).toEqual(customFields)
}) })
it('should add an expression when opened if queries are empty', () => { it('should add an expression when opened if queries are empty', () => {
@@ -101,7 +101,7 @@ describe('CustomFieldsQueryDropdownComponent', () => {
data_type: CustomFieldDataType.String, data_type: CustomFieldDataType.String,
extra_data: {}, extra_data: {},
} }
component.customFields.set([field]) component.customFields = [field]
const operators = component.getOperatorsForField(1) const operators = component.getOperatorsForField(1)
expect(operators.length).toEqual( expect(operators.length).toEqual(
[ [
@@ -138,7 +138,7 @@ describe('CustomFieldsQueryDropdownComponent', () => {
], ],
}, },
} }
component.customFields.set([field]) component.customFields = [field]
const options = component.getSelectOptionsForField(1) const options = component.getSelectOptionsForField(1)
expect(options).toEqual([ expect(options).toEqual([
{ label: 'Option 1', id: 'abc-123' }, { label: 'Option 1', id: 'abc-123' },
@@ -6,7 +6,6 @@ import {
Input, Input,
Output, Output,
QueryList, QueryList,
signal,
ViewChild, ViewChild,
ViewChildren, ViewChildren,
} from '@angular/core' } from '@angular/core'
@@ -279,7 +278,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
@Output() @Output()
selectionModelChange = new EventEmitter<CustomFieldQueriesModel>() selectionModelChange = new EventEmitter<CustomFieldQueriesModel>()
readonly customFields = signal<CustomField[]>([]) customFields: CustomField[] = []
public readonly today: string = new Date().toLocaleDateString('en-CA') public readonly today: string = new Date().toLocaleDateString('en-CA')
@@ -326,12 +325,12 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
.listAll() .listAll()
.pipe(first(), takeUntil(this.unsubscribeNotifier)) .pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe((result) => { .subscribe((result) => {
this.customFields.set(result.results) this.customFields = result.results
}) })
} }
public getCustomFieldByID(id: number): CustomField { public getCustomFieldByID(id: number): CustomField {
return this.customFields().find((field) => field.id === id) return this.customFields.find((field) => field.id === id)
} }
public addAtom(expression: CustomFieldQueryExpression) { public addAtom(expression: CustomFieldQueryExpression) {
@@ -354,7 +353,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
getOperatorsForField( getOperatorsForField(
fieldID: number fieldID: number
): Array<{ value: string; label: string }> { ): Array<{ value: string; label: string }> {
const field = this.customFields().find((field) => field.id === fieldID) const field = this.customFields.find((field) => field.id === fieldID)
const groups: CustomFieldQueryOperatorGroups[] = field const groups: CustomFieldQueryOperatorGroups[] = field
? CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE[field.data_type] ? CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE[field.data_type]
: [CustomFieldQueryOperatorGroups.Basic] : [CustomFieldQueryOperatorGroups.Basic]
@@ -370,7 +369,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
getSelectOptionsForField( getSelectOptionsForField(
fieldID: number fieldID: number
): Array<{ label: string; id: string }> { ): Array<{ label: string; id: string }> {
const field = this.customFields().find((field) => field.id === fieldID) const field = this.customFields.find((field) => field.id === fieldID)
if (field) { if (field) {
return field.extra_data['select_options'] return field.extra_data['select_options']
} }
@@ -22,11 +22,11 @@
<ng-template> <ng-template>
<div class="card mb-2"> <div class="card mb-2">
<div class="card-body p-2"> <div class="card-body p-2">
@if (testLoading()) { @if (testLoading) {
<ng-container [ngTemplateOutlet]="loadingTemplate"></ng-container> <ng-container [ngTemplateOutlet]="loadingTemplate"></ng-container>
} @else if (testResult()) { } @else if (testResult) {
<code>{{testResult()}}</code> <code>{{testResult}}</code>
} @else if (testFailed()) { } @else if (testFailed) {
<div class="text-danger" i18n>Path test failed</div> <div class="text-danger" i18n>Path test failed</div>
} @else { } @else {
<div class="text-muted small" i18n>No document selected</div> <div class="text-muted small" i18n>No document selected</div>
@@ -42,7 +42,7 @@
[compareWith]="compareDocuments" [compareWith]="compareDocuments"
[trackByFn]="trackByFn" [trackByFn]="trackByFn"
[minTermLength]="2" [minTermLength]="2"
[loading]="loading()" [loading]="loading"
[typeahead]="documentsInput$" [typeahead]="documentsInput$"
(change)="testPath($event)"> (change)="testPath($event)">
<ng-template #loadingTemplate ng-loadingspinner-tmp> <ng-template #loadingTemplate ng-loadingspinner-tmp>
@@ -58,17 +58,17 @@ describe('StoragePathEditDialogComponent', () => {
fixture.detectChanges() fixture.detectChanges()
component.testPath({ id: 1 }) component.testPath({ id: 1 })
expect(testSpy).toHaveBeenCalledWith('test/{{title}}', 1) expect(testSpy).toHaveBeenCalledWith('test/{{title}}', 1)
expect(component.testResult()).toBe('test/abc123') expect(component.testResult).toBe('test/abc123')
expect(component.testFailed()).toBeFalsy() expect(component.testFailed).toBeFalsy()
// test failed // test failed
testSpy.mockReturnValueOnce(of('')) testSpy.mockReturnValueOnce(of(''))
component.testPath({ id: 1 }) component.testPath({ id: 1 })
expect(component.testResult()).toBeNull() expect(component.testResult).toBeNull()
expect(component.testFailed()).toBeTruthy() expect(component.testFailed).toBeTruthy()
component.testPath(null) component.testPath(null)
expect(component.testResult()).toBeNull() expect(component.testResult).toBeNull()
}) })
it('should compare two documents by id', () => { it('should compare two documents by id', () => {
@@ -1,5 +1,5 @@
import { AsyncPipe, NgTemplateOutlet } from '@angular/common' import { AsyncPipe, NgTemplateOutlet } from '@angular/common'
import { Component, OnDestroy, inject, signal } from '@angular/core' import { Component, OnDestroy, inject } from '@angular/core'
import { import {
FormControl, FormControl,
FormGroup, FormGroup,
@@ -65,9 +65,9 @@ export class StoragePathEditDialogComponent
public documentsInput$ = new Subject<string>() public documentsInput$ = new Subject<string>()
public foundDocuments$: Observable<Document[]> public foundDocuments$: Observable<Document[]>
private testDocument: Document private testDocument: Document
readonly testResult = signal<string>(undefined) public testResult: string
readonly testFailed = signal(false) public testFailed: boolean = false
readonly testLoading = signal(false) public testLoading = false
constructor() { constructor() {
super() super()
@@ -99,22 +99,22 @@ export class StoragePathEditDialogComponent
public testPath(document: Document) { public testPath(document: Document) {
if (!document) { if (!document) {
this.testResult.set(null) this.testResult = null
return return
} }
this.testDocument = document this.testDocument = document
this.testLoading.set(true) this.testLoading = true
;(this.service as StoragePathService) ;(this.service as StoragePathService)
.testPath(this.objectForm.get('path').value, document.id) .testPath(this.objectForm.get('path').value, document.id)
.subscribe((result) => { .subscribe((result) => {
if (result?.length) { if (result?.length) {
this.testResult.set(result) this.testResult = result
this.testFailed.set(false) this.testFailed = false
} else { } else {
this.testResult.set(null) this.testResult = null
this.testFailed.set(true) this.testFailed = true
} }
this.testLoading.set(false) this.testLoading = false
}) })
} }
@@ -12,7 +12,7 @@
<pngx-input-color i18n-title title="Color" formControlName="color" [error]="error?.color"></pngx-input-color> <pngx-input-color i18n-title title="Color" formControlName="color" [error]="error?.color"></pngx-input-color>
<pngx-input-select i18n-title title="Parent" formControlName="parent" [items]="tags()" [allowNull]="true" [error]="error?.parent"></pngx-input-select> <pngx-input-select i18n-title title="Parent" formControlName="parent" [items]="tags" [allowNull]="true" [error]="error?.parent"></pngx-input-select>
<pngx-input-check i18n-title title="Inbox tag" formControlName="is_inbox_tag" i18n-hint hint="Inbox tags are automatically assigned to all consumed documents."></pngx-input-check> <pngx-input-check i18n-title title="Inbox tag" formControlName="is_inbox_tag" i18n-hint hint="Inbox tags are automatically assigned to all consumed documents."></pngx-input-check>
<pngx-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select> <pngx-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
@@ -1,4 +1,4 @@
import { Component, inject, signal } from '@angular/core' import { Component, inject } from '@angular/core'
import { import {
FormControl, FormControl,
FormGroup, FormGroup,
@@ -35,7 +35,7 @@ import { TextComponent } from '../../input/text/text.component'
], ],
}) })
export class TagEditDialogComponent extends EditDialogComponent<Tag> { export class TagEditDialogComponent extends EditDialogComponent<Tag> {
readonly tags = signal<Tag[]>([]) tags: Tag[]
constructor() { constructor() {
super() super()
@@ -43,7 +43,7 @@ export class TagEditDialogComponent extends EditDialogComponent<Tag> {
this.userService = inject(UserService) this.userService = inject(UserService)
this.settingsService = inject(SettingsService) this.settingsService = inject(SettingsService)
this.service.listAll().subscribe((result) => { this.service.listAll().subscribe((result) => {
this.tags.set(result.results) this.tags = result.results
}) })
} }
@@ -180,7 +180,7 @@
<div class="col"> <div class="col">
<div class="trigger-filters mb-3"> <div class="trigger-filters mb-3">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<span class="form-label mb-0" i18n>Advanced Filters</span> <label class="form-label mb-0" i18n>Advanced Filters</label>
<button <button
type="button" type="button"
class="btn btn-sm btn-outline-primary ms-auto" class="btn btn-sm btn-outline-primary ms-auto"
@@ -401,7 +401,7 @@ describe('WorkflowEditDialogComponent', () => {
component.addFilter(triggerGroup as FormGroup) component.addFilter(triggerGroup as FormGroup)
const filters = component.getFiltersFormArray(triggerGroup as FormGroup) const filters = component.getFiltersFormArray(triggerGroup as FormGroup)
expect(filters).toHaveLength(3) expect(filters.length).toBe(3)
filters.at(0).get('values').setValue([1]) filters.at(0).get('values').setValue([1])
filters.at(1).get('values').setValue([2, 3]) filters.at(1).get('values').setValue([2, 3])
@@ -672,7 +672,7 @@ describe('WorkflowEditDialogComponent', () => {
} as any } as any
const filters = component['buildFiltersFormArray'](trigger) const filters = component['buildFiltersFormArray'](trigger)
expect(filters).toHaveLength(0) expect(filters.length).toBe(0)
component.filterDefinitions = originalDefinitions component.filterDefinitions = originalDefinitions
}) })
@@ -739,7 +739,7 @@ describe('WorkflowEditDialogComponent', () => {
component.ngOnInit() component.ngOnInit()
const triggerGroup = component.triggerFields.at(0) as FormGroup const triggerGroup = component.triggerFields.at(0) as FormGroup
const filters = component.getFiltersFormArray(triggerGroup) const filters = component.getFiltersFormArray(triggerGroup)
expect(filters).toHaveLength(13) expect(filters.length).toBe(13)
const customFieldFilter = filters.at(12) as FormGroup const customFieldFilter = filters.at(12) as FormGroup
expect(customFieldFilter.get('type').value).toBe( expect(customFieldFilter.get('type').value).toBe(
TriggerFilterType.CustomFieldQuery TriggerFilterType.CustomFieldQuery
@@ -969,12 +969,12 @@ describe('WorkflowEditDialogComponent', () => {
component.addFilter(triggerGroup) component.addFilter(triggerGroup)
component.removeFilter(triggerGroup, 0) component.removeFilter(triggerGroup, 0)
expect(component.getFiltersFormArray(triggerGroup)).toHaveLength(0) expect(component.getFiltersFormArray(triggerGroup).length).toBe(0)
component.addFilter(triggerGroup) component.addFilter(triggerGroup)
const filterArrayAfterAdd = component.getFiltersFormArray(triggerGroup) const filterArrayAfterAdd = component.getFiltersFormArray(triggerGroup)
filterArrayAfterAdd.at(0).get('type').setValue(TriggerFilterType.TagsAll) filterArrayAfterAdd.at(0).get('type').setValue(TriggerFilterType.TagsAll)
expect(component.getFiltersFormArray(triggerGroup)).toHaveLength(1) expect(component.getFiltersFormArray(triggerGroup).length).toBe(1)
}) })
it('should remove selected custom field from the form group', () => { it('should remove selected custom field from the form group', () => {
@@ -933,7 +933,7 @@ export class WorkflowEditDialogComponent
getFilterSelectItems(type: TriggerFilterType) { getFilterSelectItems(type: TriggerFilterType) {
const definition = this.getFilterDefinition(type) const definition = this.getFilterDefinition(type)
if (definition?.inputType !== 'select') { if (!definition || definition.inputType !== 'select') {
return [] return []
} }
@@ -34,30 +34,32 @@
</div> </div>
@if (selectionModel.items) { @if (selectionModel.items) {
<cdk-virtual-scroll-viewport class="items" [itemSize]="FILTERABLE_BUTTON_HEIGHT_PX" #buttonsViewport [style.height.px]="scrollViewportHeight"> <cdk-virtual-scroll-viewport class="items" [itemSize]="FILTERABLE_BUTTON_HEIGHT_PX" #buttonsViewport [style.height.px]="scrollViewportHeight">
<div *cdkVirtualFor="let item of filteredItems; trackBy: trackByItem; let i = index"> <div *cdkVirtualFor="let item of selectionModel.items | filter: filterText:'name'; trackBy: trackByItem; let i = index">
<pngx-toggleable-dropdown-button @if (allowSelectNone || item.id) {
[item]="item" <pngx-toggleable-dropdown-button
[hideCount]="hideCount(item)" [item]="item"
[opacifyCount]="!editing" [hideCount]="hideCount(item)"
[state]="selectionModel.get(item.id)" [opacifyCount]="!editing"
[count]="getUpdatedDocumentCount(item.id)" [state]="selectionModel.get(item.id)"
(toggled)="selectionModel.toggle(item.id)" [count]="getUpdatedDocumentCount(item.id)"
(exclude)="excludeClicked(item.id)" (toggled)="selectionModel.toggle(item.id)"
[disabled]="disabled"> (exclude)="excludeClicked(item.id)"
</pngx-toggleable-dropdown-button> [disabled]="disabled">
</pngx-toggleable-dropdown-button>
}
</div> </div>
</cdk-virtual-scroll-viewport> </cdk-virtual-scroll-viewport>
} }
@if (editing) { @if (editing) {
@if (filteredItems.length === 0 && createRef !== undefined) { @if ((selectionModel.items | filter: filterText:'name').length === 0 && createRef !== undefined) {
<button class="list-group-item list-group-item-action bg-light" (click)="createClicked()" [disabled]="disabled"> <button class="list-group-item list-group-item-action bg-light" (click)="createClicked()" [disabled]="disabled">
<small class="ms-2"><ng-container i18n>Create</ng-container> "{{filterText}}"</small> <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 ((selectionModel.items | filter: filterText:'name').length > 0) {
<button class="list-group-item list-group-item-action bg-light d-flex align-items-center" (click)="applyClicked()" [disabled]="!modelIsDirty() || disabled"> <button class="list-group-item list-group-item-action bg-light d-flex align-items-center" (click)="applyClicked()" [disabled]="!modelIsDirty || disabled">
<small class="ms-2" [ngClass]="{'fw-bold': modelIsDirty()}" i18n>Apply</small> <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>
} }
@@ -221,7 +221,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
.dispatchEvent(new MouseEvent('click')) // open .dispatchEvent(new MouseEvent('click')) // open
selectionModel.toggle(items[0].id) selectionModel.toggle(items[0].id)
fixture.detectChanges() fixture.detectChanges()
expect(component.modelIsDirty()).toBeTruthy() expect(component.modelIsDirty).toBeTruthy()
let applyResult: ChangedItems let applyResult: ChangedItems
const closeSpy = jest.spyOn(component.dropdown, 'close') const closeSpy = jest.spyOn(component.dropdown, 'close')
component.apply.subscribe((result) => (applyResult = result)) component.apply.subscribe((result) => (applyResult = result))
@@ -244,7 +244,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
.dispatchEvent(new MouseEvent('click')) // open .dispatchEvent(new MouseEvent('click')) // open
selectionModel.toggle(items[0].id) selectionModel.toggle(items[0].id)
fixture.detectChanges() fixture.detectChanges()
expect(component.modelIsDirty()).toBeTruthy() expect(component.modelIsDirty).toBeTruthy()
let applyResult: ChangedItems let applyResult: ChangedItems
component.apply.subscribe((result) => (applyResult = result)) component.apply.subscribe((result) => (applyResult = result))
component.dropdown.close() component.dropdown.close()
@@ -265,9 +265,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
expect(document.activeElement).toEqual( expect(document.activeElement).toEqual(
component.listFilterTextInput.nativeElement component.listFilterTextInput.nativeElement
) )
expect(component.buttonsViewport.getRenderedRange().end).toEqual( expect(component.buttonsViewport.getRenderedRange().end).toEqual(3) // all items shown
items.length
) // all selectable items shown
component.filterText = 'Tag2' component.filterText = 'Tag2'
fixture.detectChanges() fixture.detectChanges()
@@ -280,29 +278,6 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
expect(component.filterText).toHaveLength(0) expect(component.filterText).toHaveLength(0)
}) })
it('should omit disallowed null items from the virtual scroll viewport', async () => {
component.selectionModel.items = items
component.icon = 'tag-fill'
fixture.nativeElement
.querySelector('button')
.dispatchEvent(new MouseEvent('click')) // open
fixture.detectChanges()
await wait(100)
fixture.detectChanges()
component.buttonsViewport?.checkViewportSize()
fixture.detectChanges()
expect(component.filteredItems).toEqual(items)
expect(component.scrollViewportHeight).toEqual(
items.length * component.FILTERABLE_BUTTON_HEIGHT_PX
)
expect(
component.buttonsViewport.elementRef.nativeElement.querySelectorAll(
'.cdk-virtual-scroll-content-wrapper > div'
)
).toHaveLength(items.length)
})
it('should toggle & close on enter inside filter field if 1 item remains', async () => { it('should toggle & close on enter inside filter field if 1 item remains', async () => {
component.selectionModel.items = items component.selectionModel.items = items
component.icon = 'tag-fill' component.icon = 'tag-fill'
@@ -12,7 +12,6 @@ import {
Output, Output,
ViewChild, ViewChild,
inject, inject,
signal,
} from '@angular/core' } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap' import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
@@ -661,6 +660,7 @@ export class FilterableDropdownSelectionModel {
imports: [ imports: [
ClearableBadgeComponent, ClearableBadgeComponent,
ToggleableDropdownButtonComponent, ToggleableDropdownButtonComponent,
FilterPipe,
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
NgxBootstrapIconsModule, NgxBootstrapIconsModule,
@@ -796,27 +796,22 @@ export class FilterableDropdownComponent
return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null
} }
readonly modelIsDirty = signal(false) modelIsDirty: boolean = false
private keyboardIndex: number private keyboardIndex: number
public get filteredItems(): MatchingModel[] {
return this.filterPipe
.transform(this.items, this.filterText, 'name')
.filter((item) => this.allowSelectNone || Boolean(item.id))
}
public get scrollViewportHeight(): number { public get scrollViewportHeight(): number {
return Math.min( const filteredLength = this.filterPipe.transform(
this.filteredItems.length * this.FILTERABLE_BUTTON_HEIGHT_PX, this.items,
400 this.filterText
) ).length
return Math.min(filteredLength * this.FILTERABLE_BUTTON_HEIGHT_PX, 400)
} }
constructor() { constructor() {
super() super()
this.selectionModelChange.subscribe((updatedModel) => { this.selectionModelChange.subscribe((updatedModel) => {
this.modelIsDirty.set(updatedModel.isDirty()) this.modelIsDirty = updatedModel.isDirty()
}) })
} }
@@ -863,7 +858,7 @@ export class FilterableDropdownComponent
}, 0) }, 0)
if (this.editing) { if (this.editing) {
this.selectionModel.reset() this.selectionModel.reset()
this.modelIsDirty.set(false) this.modelIsDirty = false
} }
this.selectionModel.singleSelect = this.selectionModel.singleSelect =
this.editing && !this.selectionModel.manyToOne this.editing && !this.selectionModel.manyToOne
@@ -882,7 +877,7 @@ export class FilterableDropdownComponent
} }
listFilterEnter(): void { listFilterEnter(): void {
const filtered = this.filteredItems let filtered = this.filterPipe.transform(this.items, this.filterText)
if (filtered.length == 1) { if (filtered.length == 1) {
this.selectionModel.toggle(filtered[0].id) this.selectionModel.toggle(filtered[0].id)
setTimeout(() => { setTimeout(() => {
@@ -5,7 +5,6 @@ import {
inject, inject,
Input, Input,
Output, Output,
signal,
} from '@angular/core' } from '@angular/core'
import { import {
FormsModule, FormsModule,
@@ -64,11 +63,11 @@ export class CustomFieldsValuesComponent extends AbstractInputComponent<Object>
super() super()
customFieldsService.listAll().subscribe((items) => { customFieldsService.listAll().subscribe((items) => {
this.fields.set(items.results) this.fields = items.results
}) })
} }
private readonly fields = signal<CustomField[]>([]) private fields: CustomField[]
private _selectedFields: number[] private _selectedFields: number[]
@@ -91,6 +90,6 @@ export class CustomFieldsValuesComponent extends AbstractInputComponent<Object>
public removeSelectedField: EventEmitter<number> = new EventEmitter<number>() public removeSelectedField: EventEmitter<number> = new EventEmitter<number>()
public getCustomField(id: number): CustomField { public getCustomField(id: number): CustomField {
return this.fields().find((field) => field.id === id) return this.fields.find((field) => field.id === id)
} }
} }
@@ -33,7 +33,7 @@
[compareWith]="compareDocuments" [compareWith]="compareDocuments"
[trackByFn]="trackByFn" [trackByFn]="trackByFn"
[minTermLength]="2" [minTermLength]="2"
[loading]="loading()" [loading]="loading"
[typeahead]="documentsInput$" [typeahead]="documentsInput$"
(mousedown)="$event.stopImmediatePropagation()" (mousedown)="$event.stopImmediatePropagation()"
(change)="onChange(selectedDocumentIDs)"> (change)="onChange(selectedDocumentIDs)">
@@ -2,11 +2,7 @@ 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 { NG_VALUE_ACCESSOR } from '@angular/forms' import { NG_VALUE_ACCESSOR } from '@angular/forms'
import { By } from '@angular/platform-browser' import { of, throwError } from 'rxjs'
import { provideRouter } from '@angular/router'
import { NgSelectComponent } from '@ng-select/ng-select'
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { of, Subject, throwError } from 'rxjs'
import { FILTER_SIMPLE_TITLE } from 'src/app/data/filter-rule-type' import { FILTER_SIMPLE_TITLE } from 'src/app/data/filter-rule-type'
import { DocumentService } from 'src/app/services/rest/document.service' import { DocumentService } from 'src/app/services/rest/document.service'
import { DocumentLinkComponent } from './document-link.component' import { DocumentLinkComponent } from './document-link.component'
@@ -37,11 +33,10 @@ describe('DocumentLinkComponent', () => {
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [DocumentLinkComponent, NgxBootstrapIconsModule.pick(allIcons)], imports: [DocumentLinkComponent],
providers: [ providers: [
provideHttpClient(withInterceptorsFromDi()), provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(), provideHttpClientTesting(),
provideRouter([]),
], ],
}) })
documentService = TestBed.inject(DocumentService) documentService = TestBed.inject(DocumentService)
@@ -65,25 +60,6 @@ describe('DocumentLinkComponent', () => {
expect(getSpy).toHaveBeenCalled() expect(getSpy).toHaveBeenCalled()
}) })
it('should render loading and selected documents after async updates', async () => {
const result$ = new Subject<any>()
jest.spyOn(documentService, 'getFew').mockReturnValue(result$)
component.writeValue([1])
await fixture.whenStable()
const select = fixture.debugElement.query(By.directive(NgSelectComponent))
.componentInstance as NgSelectComponent
expect(select.loading()).toBe(true)
result$.next({ count: 1, all: [1], results: [documents[0]] })
result$.complete()
await fixture.whenStable()
expect(select.loading()).toBe(false)
expect(fixture.nativeElement.textContent).toContain(documents[0].title)
})
it('shoud maintain ordering of selected documents', () => { it('shoud maintain ordering of selected documents', () => {
const getSpy = jest.spyOn(documentService, 'getFew') const getSpy = jest.spyOn(documentService, 'getFew')
getSpy.mockImplementation((ids) => { getSpy.mockImplementation((ids) => {
@@ -6,7 +6,6 @@ import {
Input, Input,
OnDestroy, OnDestroy,
OnInit, OnInit,
signal,
} from '@angular/core' } from '@angular/core'
import { import {
FormsModule, FormsModule,
@@ -64,7 +63,7 @@ export class DocumentLinkComponent
documentsInput$ = new Subject<string>() documentsInput$ = new Subject<string>()
foundDocuments$: Observable<Document[]> foundDocuments$: Observable<Document[]>
readonly loading = signal(false) loading = false
selectedDocuments: Document[] = [] selectedDocuments: Document[] = []
private unsubscribeNotifier: Subject<any> = new Subject() private unsubscribeNotifier: Subject<any> = new Subject()
@@ -94,12 +93,12 @@ export class DocumentLinkComponent
this.selectedDocuments = [] this.selectedDocuments = []
super.writeValue([]) super.writeValue([])
} else { } else {
this.loading.set(true) this.loading = true
this.documentsService this.documentsService
.getFew(documentIDs, { fields: 'id,title' }) .getFew(documentIDs, { fields: 'id,title' })
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((documentResults) => { .subscribe((documentResults) => {
this.loading.set(false) this.loading = false
this.selectedDocuments = documentIDs.map( this.selectedDocuments = documentIDs.map(
(id) => documentResults.results.find((d) => d.id === id) ?? {} (id) => documentResults.results.find((d) => d.id === id) ?? {}
) )
@@ -114,7 +113,7 @@ export class DocumentLinkComponent
this.documentsInput$.pipe( this.documentsInput$.pipe(
distinctUntilChanged(), distinctUntilChanged(),
takeUntil(this.unsubscribeNotifier), takeUntil(this.unsubscribeNotifier),
tap(() => this.loading.set(true)), tap(() => (this.loading = true)),
switchMap((title) => switchMap((title) =>
this.documentsService this.documentsService
.listFiltered( .listFiltered(
@@ -134,7 +133,7 @@ export class DocumentLinkComponent
) )
), ),
catchError(() => of([])), // empty on error catchError(() => of([])), // empty on error
tap(() => this.loading.set(false)) tap(() => (this.loading = false))
) )
) )
) )
@@ -37,7 +37,6 @@ export class NumberComponent extends AbstractInputComponent<number> {
this.documentService.getNextAsn().subscribe((nextAsn) => { this.documentService.getNextAsn().subscribe((nextAsn) => {
this.value = nextAsn this.value = nextAsn
this.onChange(this.value) this.onChange(this.value)
this.changeDetector.markForCheck()
}) })
} }
@@ -157,8 +157,8 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
private removeChildren(tagIDs: number[], tag: Tag) { private removeChildren(tagIDs: number[], tag: Tag) {
if (tag.children?.length) { if (tag.children?.length) {
const childIDs = new Set(tag.children.map((child) => child.id)) const childIDs = tag.children.map((child) => child.id)
tagIDs = tagIDs.filter((id) => !childIDs.has(id)) tagIDs = tagIDs.filter((id) => !childIDs.includes(id))
for (const child of tag.children) { for (const child of tag.children) {
tagIDs = this.removeChildren(tagIDs, child) tagIDs = this.removeChildren(tagIDs, child)
} }
@@ -14,7 +14,7 @@ import { environment } from 'src/environments/environment'
}) })
export class PageHeaderComponent { export class PageHeaderComponent {
private titleService = inject(Title) private titleService = inject(Title)
private readonly clipboard = inject(Clipboard) private clipboard = inject(Clipboard)
readonly id = input<number>(undefined) readonly id = input<number>(undefined)
readonly subTitle = input('') readonly subTitle = input('')
@@ -49,7 +49,8 @@ describe('PngxPdfViewerComponent', () => {
new URL('assets/js/pdf.worker.min.mjs', document.baseURI).toString() new URL('assets/js/pdf.worker.min.mjs', document.baseURI).toString()
) )
const isVisible = (component as any).findController.onIsPageVisible as const isVisible = (component as any).findController.onIsPageVisible as
(() => boolean) | undefined | (() => boolean)
| undefined
expect(isVisible?.()).toBe(true) expect(isVisible?.()).toBe(true)
expect(loadSpy).toHaveBeenCalledWith( expect(loadSpy).toHaveBeenCalledWith(
expect.objectContaining({ numPages: 1 }) expect.objectContaining({ numPages: 1 })
@@ -60,7 +61,6 @@ describe('PngxPdfViewerComponent', () => {
it('resolves the worker source relative to the document base URI', async () => { it('resolves the worker source relative to the document base URI', async () => {
setBaseHref('/paperless/') setBaseHref('/paperless/')
const getDocumentSpy = jest.spyOn(pdfjs, 'getDocument')
await initComponent() await initComponent()
@@ -70,13 +70,6 @@ describe('PngxPdfViewerComponent', () => {
expect(pdfjs.GlobalWorkerOptions.workerSrc).toContain( expect(pdfjs.GlobalWorkerOptions.workerSrc).toContain(
'/paperless/assets/js/pdf.worker.min.mjs' '/paperless/assets/js/pdf.worker.min.mjs'
) )
expect(getDocumentSpy).toHaveBeenCalledWith({
url: 'test.pdf',
password: undefined,
withCredentials: true,
wasmUrl: expect.stringContaining('/paperless/assets/wasm/'),
iccUrl: expect.stringContaining('/paperless/assets/iccs/'),
})
}) })
it('initializes single-page viewer and disables text layer', async () => { it('initializes single-page viewer and disables text layer', async () => {
@@ -138,18 +131,6 @@ describe('PngxPdfViewerComponent', () => {
expect(applyScaleSpy).toHaveBeenCalled() expect(applyScaleSpy).toHaveBeenCalled()
}) })
it('does not reset the viewer when it is already on the requested page', async () => {
await initComponent()
const viewer = (component as any).pdfViewer as PDFViewer
const currentPageSpy = jest.spyOn(viewer, 'currentPageNumber', 'set')
component.page = viewer.currentPageNumber
;(component as any).applyViewerState()
expect(currentPageSpy).not.toHaveBeenCalled()
})
it('dispatches find when search query changes after render', async () => { it('dispatches find when search query changes after render', async () => {
await initComponent() await initComponent()
@@ -174,12 +174,10 @@ export class PngxPdfViewerComponent
'assets/js/pdf.worker.min.mjs', 'assets/js/pdf.worker.min.mjs',
this.document.baseURI this.document.baseURI
).toString() ).toString()
const initOptions = { let initOptions = {
url: this.src, url: this.src,
password: this.password, password: this.password,
withCredentials: true, withCredentials: true,
wasmUrl: new URL('assets/wasm/', this.document.baseURI).toString(),
iccUrl: new URL('assets/iccs/', this.document.baseURI).toString(),
} }
this.loadingTask = getDocument(initOptions) this.loadingTask = getDocument(initOptions)
try { try {
@@ -256,9 +254,7 @@ export class PngxPdfViewerComponent
Math.max(Math.trunc(this.page), 1), Math.max(Math.trunc(this.page), 1),
this.pdfViewer.pagesCount this.pdfViewer.pagesCount
) )
if (nextPage !== this.pdfViewer.currentPageNumber) { this.pdfViewer.currentPageNumber = nextPage
this.pdfViewer.currentPageNumber = nextPage
}
} }
if (this.page === this.lastViewerPage) { if (this.page === this.lastViewerPage) {
this.lastViewerPage = undefined this.lastViewerPage = undefined
@@ -68,7 +68,7 @@
[(ngModel)]="selectionModel.includeUsers" [(ngModel)]="selectionModel.includeUsers"
[disabled]="disabled" [disabled]="disabled"
[clearable]="false" [clearable]="false"
[items]="users()" [items]="users"
bindLabel="username" bindLabel="username"
multiple="true" multiple="true"
bindValue="id" bindValue="id"
@@ -1,12 +1,5 @@
import { NgClass } from '@angular/common' import { NgClass } from '@angular/common'
import { import { Component, EventEmitter, Input, Output, inject } from '@angular/core'
Component,
EventEmitter,
Input,
Output,
inject,
signal,
} from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap' import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectComponent } from '@ng-select/ng-select' import { NgSelectComponent } from '@ng-select/ng-select'
@@ -82,7 +75,7 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
@Output() @Output()
ownerFilterSet = new EventEmitter<PermissionsSelectionModel>() ownerFilterSet = new EventEmitter<PermissionsSelectionModel>()
readonly users = signal<User[]>([]) users: User[]
hideUnowned: boolean hideUnowned: boolean
@@ -109,7 +102,7 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
.listAll() .listAll()
.pipe(first()) .pipe(first())
.subscribe({ .subscribe({
next: (result) => this.users.set(result.results), next: (result) => (this.users = result.results),
}) })
} }
} }
@@ -57,15 +57,15 @@
</div> </div>
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
@if (socialAccounts().length > 0) { @if (socialAccounts?.length > 0) {
<div class="mb-3"> <div class="mb-3">
<p i18n>Connected social accounts</p> <p i18n>Connected social accounts</p>
<ul class="list-group"> <ul class="list-group">
@for (account of socialAccounts(); track account.id) { @for (account of socialAccounts; track account.id) {
<li class="list-group-item" <li class="list-group-item"
ngbPopover="Set a password before disconnecting social account." ngbPopover="Set a password before disconnecting social account."
i18n-ngbPopover i18n-ngbPopover
[disablePopover]="hasUsablePassword()" [disablePopover]="hasUsablePassword"
triggers="mouseenter:mouseleave"> triggers="mouseenter:mouseleave">
{{account.name}} ({{account.provider}}) {{account.name}} ({{account.provider}})
<pngx-confirm-button <pngx-confirm-button
@@ -75,7 +75,7 @@
i18n-title i18n-title
buttonClasses="btn-outline-danger btn-sm ms-2 align-baseline" buttonClasses="btn-outline-danger btn-sm ms-2 align-baseline"
iconName="trash" iconName="trash"
[disabled]="!hasUsablePassword()" [disabled]="!hasUsablePassword"
(confirm)="disconnectSocialAccount(account.id)"> (confirm)="disconnectSocialAccount(account.id)">
</pngx-confirm-button> </pngx-confirm-button>
</li> </li>
@@ -84,11 +84,11 @@
<div class="form-text text-muted text-end fst-italic" i18n>Warning: disconnecting social accounts cannot be undone</div> <div class="form-text text-muted text-end fst-italic" i18n>Warning: disconnecting social accounts cannot be undone</div>
</div> </div>
} }
@if (socialAccountProviders().length > 0) { @if (socialAccountProviders?.length > 0) {
<div class="mb-3"> <div class="mb-3">
<p i18n>Connect new social account</p> <p i18n>Connect new social account</p>
<div class="list-group"> <div class="list-group">
@for (provider of socialAccountProviders(); track provider.name) { @for (provider of socialAccountProviders; track provider.name) {
<a class="list-group-item list-group-item-action text-primary d-flex align-items-center" href="{{ provider.login_url }}" rel="noopener noreferrer"> <a class="list-group-item list-group-item-action text-primary d-flex align-items-center" href="{{ provider.login_url }}" rel="noopener noreferrer">
{{provider.name}}<i-bs class="pb-1 ms-2" name="box-arrow-up-right"></i-bs> {{provider.name}}<i-bs class="pb-1 ms-2" name="box-arrow-up-right"></i-bs>
</a> </a>
@@ -96,7 +96,7 @@
</div> </div>
</div> </div>
} }
@if (!isTotpEnabled()) { @if (!isTotpEnabled) {
<div ngbAccordion> <div ngbAccordion>
<div ngbAccordionItem> <div ngbAccordionItem>
<h2 ngbAccordionHeader> <h2 ngbAccordionHeader>
@@ -105,10 +105,10 @@
<div ngbAccordionCollapse> <div ngbAccordionCollapse>
<div ngbAccordionBody> <div ngbAccordionBody>
<ng-template> <ng-template>
@if (totpSettingsLoading()) { @if (totpSettingsLoading) {
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div> <div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div> <div class="visually-hidden" i18n>Loading...</div>
} @else if (totpSettings()) { } @else if (totpSettings) {
<figure class="figure"> <figure class="figure">
@if (qrSvgDataUrl) { @if (qrSvgDataUrl) {
<img class="bg-white d-inline-block" [src]="qrSvgDataUrl" alt="Authenticator QR code"> <img class="bg-white d-inline-block" [src]="qrSvgDataUrl" alt="Authenticator QR code">
@@ -116,14 +116,14 @@
<figcaption class="figure-caption text-end mt-2" i18n>Scan the QR code with your authenticator app and then enter the code below</figcaption> <figcaption class="figure-caption text-end mt-2" i18n>Scan the QR code with your authenticator app and then enter the code below</figcaption>
</figure> </figure>
<p> <p>
<ng-container i18n>Authenticator secret</ng-container>: <code>{{totpSettings().secret}}</code>. <ng-container i18n>Authenticator secret</ng-container>: <code>{{totpSettings.secret}}</code>.
<ng-container i18n>You can store this secret and use it to reinstall your authenticator app at a later time.</ng-container> <ng-container i18n>You can store this secret and use it to reinstall your authenticator app at a later time.</ng-container>
</p> </p>
<div class="input-group mb-3"> <div class="input-group mb-3">
<input type="text" class="form-control" formControlName="totp_code" placeholder="Code" i18n-placeholder> <input type="text" class="form-control" formControlName="totp_code" placeholder="Code" i18n-placeholder>
<button type="button" class="btn btn-primary ml-auto" (click)="activateTotp()" [disabled]="totpLoading()"> <button type="button" class="btn btn-primary ml-auto" (click)="activateTotp()" [disabled]="totpLoading">
<ng-container i18n>Enable</ng-container> <ng-container i18n>Enable</ng-container>
@if (totpLoading()) { @if (totpLoading) {
<div class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div> <div class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div> <div class="visually-hidden" i18n>Loading...</div>
} }
@@ -137,18 +137,18 @@
</div> </div>
} @else { } @else {
<label class="d-block mb-2" i18n>Two-factor Authentication</label> <label class="d-block mb-2" i18n>Two-factor Authentication</label>
@if (recoveryCodes()) { @if (recoveryCodes) {
<div class="alert alert-warning" role="alert"> <div class="alert alert-warning" role="alert">
<i-bs name="exclamation-triangle" class="me-1"></i-bs><ng-container i18n>Recovery codes will not be shown again, make sure to save them.</ng-container> <i-bs name="exclamation-triangle" class="me-1"></i-bs><ng-container i18n>Recovery codes will not be shown again, make sure to save them.</ng-container>
</div> </div>
<div class="d-flex flex-row align-items-start mb-3"> <div class="d-flex flex-row align-items-start mb-3">
<ul class="list-group w-50"> <ul class="list-group w-50">
@for (code of recoveryCodes(); track code; let i = $index) { @for (code of recoveryCodes; track code; let i = $index) {
@if (i % 2 === 0) { @if (i % 2 === 0) {
<li class="list-group-item d-flex justify-content-around align-items-center"> <li class="list-group-item d-flex justify-content-around align-items-center">
<code>{{code}}</code> <code>{{code}}</code>
@if (recoveryCodes()[i + 1]) { @if (recoveryCodes[i + 1]) {
<code>{{recoveryCodes()[i + 1]}}</code> <code>{{recoveryCodes[i + 1]}}</code>
} }
</li> </li>
} }
@@ -171,7 +171,7 @@
i18n-title i18n-title
buttonClasses="btn-outline-danger btn-sm" buttonClasses="btn-outline-danger btn-sm"
iconName="trash" iconName="trash"
[disabled]="totpLoading()" [disabled]="totpLoading"
(confirm)="deactivateTotp()"> (confirm)="deactivateTotp()">
</pngx-confirm-button> </pngx-confirm-button>
} }
@@ -10,7 +10,7 @@ import {
NgbPopoverModule, NgbPopoverModule,
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { NEVER, of, Subject, throwError } from 'rxjs' import { of, throwError } from 'rxjs'
import { ProfileService } from 'src/app/services/profile.service' import { ProfileService } from 'src/app/services/profile.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import * as navUtils from 'src/app/utils/navigation' import * as navUtils from 'src/app/utils/navigation'
@@ -60,10 +60,6 @@ describe('ProfileEditDialogComponent', () => {
providers: [NgbActiveModal, provideHttpClient(withInterceptorsFromDi())], providers: [NgbActiveModal, provideHttpClient(withInterceptorsFromDi())],
}) })
profileService = TestBed.inject(ProfileService) profileService = TestBed.inject(ProfileService)
jest.spyOn(profileService, 'get').mockReturnValue(NEVER)
jest
.spyOn(profileService, 'getSocialAccountProviders')
.mockReturnValue(of([]))
toastService = TestBed.inject(ToastService) toastService = TestBed.inject(ToastService)
clipboard = TestBed.inject(Clipboard) clipboard = TestBed.inject(Clipboard)
fixture = TestBed.createComponent(ProfileEditDialogComponent) fixture = TestBed.createComponent(ProfileEditDialogComponent)
@@ -159,7 +155,7 @@ describe('ProfileEditDialogComponent', () => {
'getSocialAccountProviders' 'getSocialAccountProviders'
) )
getProvidersSpy.mockReturnValue(of(socialAccountProviders)) getProvidersSpy.mockReturnValue(of(socialAccountProviders))
component.hasUsablePassword.set(true) component.hasUsablePassword = true
component.ngOnInit() component.ngOnInit()
component.form.get('password').patchValue('new*pass') component.form.get('password').patchValue('new*pass')
component.onPasswordKeyUp({ component.onPasswordKeyUp({
@@ -272,27 +268,6 @@ describe('ProfileEditDialogComponent', () => {
expect(getProvidersSpy).toHaveBeenCalled() expect(getProvidersSpy).toHaveBeenCalled()
}) })
it('should render social account providers after an async update', async () => {
const providers$ = new Subject<typeof socialAccountProviders>()
jest.spyOn(profileService, 'get').mockReturnValue(of(profile))
jest
.spyOn(profileService, 'getSocialAccountProviders')
.mockReturnValue(providers$)
component.ngOnInit()
await fixture.whenStable()
expect(
fixture.nativeElement.querySelector('a[href="https://example.com"]')
).toBeNull()
providers$.next(socialAccountProviders)
await fixture.whenStable()
expect(
fixture.nativeElement.querySelector('a[href="https://example.com"]')
).not.toBeNull()
})
it('should remove disconnected social account from component, show error if needed', () => { it('should remove disconnected social account from component, show error if needed', () => {
const disconnectSpy = jest.spyOn(profileService, 'disconnectSocialAccount') const disconnectSpy = jest.spyOn(profileService, 'disconnectSocialAccount')
const getSpy = jest.spyOn(profileService, 'get') const getSpy = jest.spyOn(profileService, 'get')
@@ -301,7 +276,7 @@ describe('ProfileEditDialogComponent', () => {
const errorSpy = jest.spyOn(toastService, 'showError') const errorSpy = jest.spyOn(toastService, 'showError')
expect(component.socialAccounts()).toContainEqual(socialAccount) expect(component.socialAccounts).toContainEqual(socialAccount)
// fail first // fail first
disconnectSpy.mockReturnValueOnce( disconnectSpy.mockReturnValueOnce(
@@ -314,7 +289,7 @@ describe('ProfileEditDialogComponent', () => {
disconnectSpy.mockReturnValue(of(socialAccount.id)) disconnectSpy.mockReturnValue(of(socialAccount.id))
component.disconnectSocialAccount(socialAccount.id) component.disconnectSocialAccount(socialAccount.id)
expect(disconnectSpy).toHaveBeenCalled() expect(disconnectSpy).toHaveBeenCalled()
expect(component.socialAccounts()).not.toContainEqual(socialAccount) expect(component.socialAccounts).not.toContainEqual(socialAccount)
}) })
it('should get totp settings', () => { it('should get totp settings', () => {
@@ -335,7 +310,7 @@ describe('ProfileEditDialogComponent', () => {
getSpy.mockReturnValue(of(settings)) getSpy.mockReturnValue(of(settings))
component.gettotpSettings() component.gettotpSettings()
expect(getSpy).toHaveBeenCalled() expect(getSpy).toHaveBeenCalled()
expect(component.totpSettings()).toEqual(settings) expect(component.totpSettings).toEqual(settings)
}) })
it('should activate totp', () => { it('should activate totp', () => {
@@ -344,15 +319,15 @@ describe('ProfileEditDialogComponent', () => {
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') 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 = {
url: 'http://localhost/', url: 'http://localhost/',
qr_svg: 'svg', qr_svg: 'svg',
secret: 'secret', secret: 'secret',
}) }
component.form.get('totp_code').patchValue('123456') component.form.get('totp_code').patchValue('123456')
component.activateTotp() component.activateTotp()
expect(activateSpy).toHaveBeenCalledWith( expect(activateSpy).toHaveBeenCalledWith(
component.totpSettings().secret, component.totpSettings.secret,
component.form.get('totp_code').value component.form.get('totp_code').value
) )
expect(toastErrorSpy).toHaveBeenCalled() expect(toastErrorSpy).toHaveBeenCalled()
@@ -366,8 +341,8 @@ describe('ProfileEditDialogComponent', () => {
) )
component.activateTotp() component.activateTotp()
expect(toastInfoSpy).toHaveBeenCalled() expect(toastInfoSpy).toHaveBeenCalled()
expect(component.isTotpEnabled()).toBeTruthy() expect(component.isTotpEnabled).toBeTruthy()
expect(component.recoveryCodes()).toEqual(['1', '2', '3']) expect(component.recoveryCodes).toEqual(['1', '2', '3'])
}) })
it('should deactivate totp', () => { it('should deactivate totp', () => {
@@ -387,13 +362,13 @@ describe('ProfileEditDialogComponent', () => {
deactivateSpy.mockReturnValueOnce(of(true)) deactivateSpy.mockReturnValueOnce(of(true))
component.deactivateTotp() component.deactivateTotp()
expect(toastInfoSpy).toHaveBeenCalled() expect(toastInfoSpy).toHaveBeenCalled()
expect(component.isTotpEnabled()).toBeFalsy() expect(component.isTotpEnabled).toBeFalsy()
}) })
it('should copy recovery codes', () => { it('should copy recovery codes', () => {
jest.useFakeTimers() jest.useFakeTimers()
const copySpy = jest.spyOn(clipboard, 'copy') const copySpy = jest.spyOn(clipboard, 'copy')
component.recoveryCodes.set(['1', '2', '3']) component.recoveryCodes = ['1', '2', '3']
component.copyRecoveryCodes() component.copyRecoveryCodes()
expect(copySpy).toHaveBeenCalledWith('1\n2\n3') expect(copySpy).toHaveBeenCalledWith('1\n2\n3')
jest.advanceTimersByTime(3000) jest.advanceTimersByTime(3000)
@@ -56,14 +56,6 @@ export class ProfileEditDialogComponent
readonly showEmailConfirm = signal(false) readonly showEmailConfirm = signal(false)
readonly copied = signal(false) readonly copied = signal(false)
readonly codesCopied = signal(false) readonly codesCopied = signal(false)
readonly hasUsablePassword = signal(false)
readonly isTotpEnabled = signal(false)
readonly totpSettings = signal<TotpSettings>(undefined)
readonly totpSettingsLoading = signal(false)
readonly totpLoading = signal(false)
readonly recoveryCodes = signal<string[]>(undefined)
readonly socialAccounts = signal<SocialAccount[]>([])
readonly socialAccountProviders = signal<SocialAccountProvider[]>([])
public form = new FormGroup({ public form = new FormGroup({
email: new FormControl(''), email: new FormControl(''),
@@ -80,15 +72,25 @@ export class ProfileEditDialogComponent
private newPassword: string private newPassword: string
private passwordConfirm: string private passwordConfirm: string
public hasUsablePassword: boolean = false
private currentEmail: string private currentEmail: string
private newEmail: string private newEmail: string
private emailConfirm: string private emailConfirm: string
public isTotpEnabled: boolean = false
public totpSettings: TotpSettings
public totpSettingsLoading: boolean = false
public totpLoading: boolean = false
public recoveryCodes: string[]
public socialAccounts: SocialAccount[] = []
public socialAccountProviders: SocialAccountProvider[] = []
get qrSvgDataUrl(): string | null { get qrSvgDataUrl(): string | null {
if (!this.totpSettings()?.qr_svg) { if (!this.totpSettings?.qr_svg) {
return null return null
} }
return `data:image/svg+xml;utf8,${encodeURIComponent(this.totpSettings().qr_svg)}` return `data:image/svg+xml;utf8,${encodeURIComponent(this.totpSettings.qr_svg)}`
} }
ngOnInit(): void { ngOnInit(): void {
@@ -105,20 +107,20 @@ export class ProfileEditDialogComponent
this.onEmailChange() this.onEmailChange()
}) })
this.currentPassword = profile.password this.currentPassword = profile.password
this.hasUsablePassword.set(profile.has_usable_password) this.hasUsablePassword = profile.has_usable_password
this.form.get('password').valueChanges.subscribe((newPassword) => { this.form.get('password').valueChanges.subscribe((newPassword) => {
this.newPassword = newPassword this.newPassword = newPassword
this.onPasswordChange() this.onPasswordChange()
}) })
this.socialAccounts.set(profile.social_accounts ?? []) this.socialAccounts = profile.social_accounts
this.isTotpEnabled.set(profile.is_mfa_enabled) this.isTotpEnabled = profile.is_mfa_enabled
}) })
this.profileService this.profileService
.getSocialAccountProviders() .getSocialAccountProviders()
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((providers) => { .subscribe((providers) => {
this.socialAccountProviders.set(providers ?? []) this.socialAccountProviders = providers
}) })
} }
@@ -257,9 +259,7 @@ export class ProfileEditDialogComponent
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({
next: (id: number) => { next: (id: number) => {
this.socialAccounts.update((accounts) => this.socialAccounts = this.socialAccounts.filter((a) => a.id != id)
accounts.filter((account) => account.id != id)
)
}, },
error: (error) => { error: (error) => {
this.toastService.showError( this.toastService.showError(
@@ -271,39 +271,36 @@ export class ProfileEditDialogComponent
} }
public gettotpSettings(): void { public gettotpSettings(): void {
this.totpSettingsLoading.set(true) this.totpSettingsLoading = true
this.profileService this.profileService
.getTotpSettings() .getTotpSettings()
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({
next: (totpSettings) => { next: (totpSettings) => {
this.totpSettingsLoading.set(false) this.totpSettingsLoading = false
this.totpSettings.set(totpSettings) this.totpSettings = totpSettings
}, },
error: (error) => { error: (error) => {
this.toastService.showError( this.toastService.showError(
$localize`Error fetching TOTP settings`, $localize`Error fetching TOTP settings`,
error error
) )
this.totpSettingsLoading.set(false) this.totpSettingsLoading = false
}, },
}) })
} }
public activateTotp(): void { public activateTotp(): void {
this.totpLoading.set(true) this.totpLoading = true
this.form.get('totp_code').disable() this.form.get('totp_code').disable()
this.profileService this.profileService
.activateTotp( .activateTotp(this.totpSettings.secret, this.form.get('totp_code').value)
this.totpSettings().secret,
this.form.get('totp_code').value
)
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({
next: (activationResponse) => { next: (activationResponse) => {
this.totpLoading.set(false) this.totpLoading = false
this.isTotpEnabled.set(activationResponse.success) this.isTotpEnabled = activationResponse.success
this.recoveryCodes.set(activationResponse.recovery_codes) this.recoveryCodes = activationResponse.recovery_codes
this.form.get('totp_code').enable() this.form.get('totp_code').enable()
if (activationResponse.success) { if (activationResponse.success) {
this.toastService.showInfo($localize`TOTP activated successfully`) this.toastService.showInfo($localize`TOTP activated successfully`)
@@ -312,7 +309,7 @@ export class ProfileEditDialogComponent
} }
}, },
error: (error) => { error: (error) => {
this.totpLoading.set(false) this.totpLoading = false
this.form.get('totp_code').enable() this.form.get('totp_code').enable()
this.toastService.showError($localize`Error activating TOTP`, error) this.toastService.showError($localize`Error activating TOTP`, error)
}, },
@@ -320,15 +317,15 @@ export class ProfileEditDialogComponent
} }
public deactivateTotp(): void { public deactivateTotp(): void {
this.totpLoading.set(true) this.totpLoading = true
this.profileService this.profileService
.deactivateTotp() .deactivateTotp()
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({
next: (success) => { next: (success) => {
this.totpLoading.set(false) this.totpLoading = false
this.isTotpEnabled.set(!success) this.isTotpEnabled = !success
this.recoveryCodes.set(null) this.recoveryCodes = null
if (success) { if (success) {
this.toastService.showInfo($localize`TOTP deactivated successfully`) this.toastService.showInfo($localize`TOTP deactivated successfully`)
} else { } else {
@@ -336,14 +333,14 @@ export class ProfileEditDialogComponent
} }
}, },
error: (error) => { error: (error) => {
this.totpLoading.set(false) this.totpLoading = false
this.toastService.showError($localize`Error deactivating TOTP`, error) this.toastService.showError($localize`Error deactivating TOTP`, error)
}, },
}) })
} }
public copyRecoveryCodes(): void { public copyRecoveryCodes(): void {
this.clipboard.copy(this.recoveryCodes().join('\n')) this.clipboard.copy(this.recoveryCodes.join('\n'))
this.codesCopied.set(true) this.codesCopied.set(true)
setTimeout(() => { setTimeout(() => {
this.codesCopied.set(false) this.codesCopied.set(false)
@@ -65,9 +65,8 @@
<dd class="col-sm-8"><code>{{ createdBundle.slug }}</code></dd> <dd class="col-sm-8"><code>{{ createdBundle.slug }}</code></dd>
<dt class="col-sm-4" i18n>Link</dt> <dt class="col-sm-4" i18n>Link</dt>
<dd class="col-sm-8"> <dd class="col-sm-8">
<label class="visually-hidden" for="shareBundleLink" i18n>Share link</label>
<div class="input-group input-group-sm"> <div class="input-group input-group-sm">
<input id="shareBundleLink" class="form-control" type="text" [value]="getShareUrl(createdBundle)" readonly> <input class="form-control" type="text" [value]="getShareUrl(createdBundle)" readonly>
<button <button
class="btn btn-outline-primary" class="btn btn-outline-primary"
type="button" type="button"
@@ -14,9 +14,7 @@
} }
<div content class="wrapper fade" [class.show]="show()"> <div content class="wrapper fade" [class.show]="show()">
@if (error()) { @if (displayMode() === DisplayMode.TABLE) {
<div class="alert alert-danger mb-0" role="alert"><ng-container i18n>Error while loading documents</ng-container>: {{error()}}</div>
} @else if (displayMode() === DisplayMode.TABLE) {
<table class="table table-hover mb-0 mt-n2 align-middle"> <table class="table table-hover mb-0 mt-n2 align-middle">
<thead> <thead>
<tr> <tr>
@@ -1,10 +1,6 @@
import { DragDropModule } from '@angular/cdk/drag-drop' import { DragDropModule } from '@angular/cdk/drag-drop'
import { DatePipe } from '@angular/common' import { DatePipe } from '@angular/common'
import { import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
HttpErrorResponse,
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 { By } from '@angular/platform-browser'
@@ -12,7 +8,7 @@ import { Router } from '@angular/router'
import { RouterTestingModule } from '@angular/router/testing' import { RouterTestingModule } from '@angular/router/testing'
import { NgbModule } from '@ng-bootstrap/ng-bootstrap' import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { Subject, of, throwError } from 'rxjs' import { Subject, of } from 'rxjs'
import { routes } from 'src/app/app-routing.module' import { routes } from 'src/app/app-routing.module'
import { CustomFieldDisplayComponent } from 'src/app/components/common/custom-field-display/custom-field-display.component' import { CustomFieldDisplayComponent } from 'src/app/components/common/custom-field-display/custom-field-display.component'
import { PreviewPopupComponent } from 'src/app/components/common/preview-popup/preview-popup.component' import { PreviewPopupComponent } from 'src/app/components/common/preview-popup/preview-popup.component'
@@ -234,27 +230,6 @@ describe('SavedViewWidgetComponent', () => {
expect(component.documents()).toEqual(documentResults) expect(component.documents()).toEqual(documentResults)
}) })
it('should show an error if documents fail to load', () => {
jest.spyOn(documentService, 'listFiltered').mockReturnValue(
throwError(
() =>
new HttpErrorResponse({
error: { added__date__lte: ['Enter a valid date.'] },
status: 400,
})
)
)
component.reload()
fixture.detectChanges()
expect(component.loading()).toBe(false)
expect(component.error()).toEqual('Added: Enter a valid date.')
expect(fixture.debugElement.nativeElement.textContent).toContain(
'Error while loading documents: Added: Enter a valid date.'
)
})
it('should reload on document consumption finished', () => { it('should reload on document consumption finished', () => {
const fileStatusSubject = new Subject<FileStatus>() const fileStatusSubject = new Subject<FileStatus>()
jest jest
@@ -125,8 +125,6 @@ export class SavedViewWidgetComponent
readonly count = signal<number>(null) readonly count = signal<number>(null)
readonly error = signal<string | null>(null)
placeholderRows: number[] = [] placeholderRows: number[] = []
ngOnInit(): void { ngOnInit(): void {
@@ -182,7 +180,6 @@ export class SavedViewWidgetComponent
reload() { reload() {
this.loading.set(this.documents().length == 0) this.loading.set(this.documents().length == 0)
this.error.set(null)
this.show.set(true) this.show.set(true)
this.documentService this.documentService
.listFiltered( .listFiltered(
@@ -194,40 +191,12 @@ export class SavedViewWidgetComponent
{ truncate_content: true } { truncate_content: true }
) )
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe((result) => {
next: (result) => { this.documents.set(result.results)
this.documents.set(result.results) this.count.set(result.count)
this.count.set(result.count) this.savedViewService.setDocumentCount(this.savedView, result.count)
this.savedViewService.setDocumentCount(this.savedView, result.count) this.loading.set(false)
this.loading.set(false) this.show.set(true)
this.show.set(true)
},
error: (error) => {
this.documents.set([])
this.count.set(null)
let errorMessage
if (
typeof error.error === 'object' &&
Object.keys(error.error).length > 0
) {
errorMessage = Object.keys(error.error)
.map((fieldName) => {
const fieldNameBase = fieldName.split('__')[0]
const fieldError: Array<string> = error.error[fieldName]
return `${
this.documentService.sortFields.find(
(f) => f.field?.split('__')[0] == fieldNameBase
)?.name ?? fieldNameBase
}: ${fieldError[0]}`
})
.join(', ')
} else {
errorMessage = error.error
}
this.error.set(errorMessage)
this.loading.set(false)
this.show.set(true)
},
}) })
} }
@@ -1 +1 @@
<p i18n>Searching document with asn {{asn()}}</p> <p i18n>Searching document with asn {{asn}}</p>
@@ -1,4 +1,4 @@
import { Component, OnInit, inject, signal } from '@angular/core' import { Component, OnInit, inject } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router' import { ActivatedRoute, Router } from '@angular/router'
import { FILTER_ASN } from '../../data/filter-rule-type' import { FILTER_ASN } from '../../data/filter-rule-type'
import { DocumentService } from '../../services/rest/document.service' import { DocumentService } from '../../services/rest/document.service'
@@ -13,13 +13,13 @@ export class DocumentAsnComponent implements OnInit {
private route = inject(ActivatedRoute) private route = inject(ActivatedRoute)
private router = inject(Router) private router = inject(Router)
readonly asn = signal<string>(undefined) asn: string
ngOnInit(): void { ngOnInit(): void {
this.route.paramMap.subscribe((paramMap) => { this.route.paramMap.subscribe((paramMap) => {
this.asn.set(paramMap.get('id')) this.asn = paramMap.get('id')
this.documentsService this.documentsService
.listAllFilteredIds([{ rule_type: FILTER_ASN, value: this.asn() }]) .listAllFilteredIds([{ rule_type: FILTER_ASN, value: this.asn }])
.subscribe((documentId) => { .subscribe((documentId) => {
if (documentId.length == 1) { if (documentId.length == 1) {
this.router.navigate(['documents', documentId[0]]) this.router.navigate(['documents', documentId[0]])
@@ -315,7 +315,7 @@
<td>{{metadata()?.original_filename}}</td> <td>{{metadata()?.original_filename}}</td>
</tr> </tr>
<tr> <tr>
<td i18n>Original SHA256 checksum</td> <td i18n>Original MD5 checksum</td>
<td>{{metadata()?.original_checksum}}</td> <td>{{metadata()?.original_checksum}}</td>
</tr> </tr>
<tr> <tr>
@@ -328,7 +328,7 @@
</tr> </tr>
@if (metadata()?.has_archive_version) { @if (metadata()?.has_archive_version) {
<tr> <tr>
<td i18n>Archive SHA256 checksum</td> <td i18n>Archive MD5 checksum</td>
<td>{{metadata()?.archive_checksum}}</td> <td>{{metadata()?.archive_checksum}}</td>
</tr> </tr>
} }
@@ -482,7 +482,9 @@
</pngx-pdf-viewer> </pngx-pdf-viewer>
</div> </div>
} @else { } @else {
<object [data]="previewUrl() | safeUrl" class="preview-sticky" width="100%"></object> <object [data]="previewUrl() | safeUrl" class="preview-sticky" width="100%">
<span>Preview is unavailable.</span>
</object>
} }
} }
@case (ContentRenderType.Text) { @case (ContentRenderType.Text) {
@@ -503,7 +505,9 @@
} }
} }
@case (ContentRenderType.Other) { @case (ContentRenderType.Other) {
<object [data]="previewUrl() | safeUrl" class="preview-sticky" width="100%"></object> <object [data]="previewUrl() | safeUrl" class="preview-sticky" width="100%">
<span>Preview is unavailable.</span>
</object>
} }
} }
@if (requiresPassword) { @if (requiresPassword) {
@@ -510,7 +510,7 @@ describe('DocumentDetailComponent', () => {
const deleteButtons = fixture.debugElement.queryAll( const deleteButtons = fixture.debugElement.queryAll(
By.css('pngx-confirm-button') By.css('pngx-confirm-button')
) )
expect(deleteButtons).toHaveLength(1) expect(deleteButtons.length).toEqual(1)
}) })
it('should fall back to details tab when duplicates tab is active but no duplicates', () => { it('should fall back to details tab when duplicates tab is active but no duplicates', () => {
@@ -1,6 +1,6 @@
@if (loading()) { @if (loading()) {
<div class="d-flex"> <div class="d-flex">
<output class="spinner-border spinner-border-sm fw-normal"></output> <output class="spinner-border spinner-border-sm fw-normal" role="status"></output>
</div> </div>
} @else { } @else {
<ul class="list-group"> <ul class="list-group">
@@ -26,11 +26,11 @@ import { UserService } from 'src/app/services/rest/user.service'
], ],
}) })
export class DocumentHistoryComponent implements OnInit { export class DocumentHistoryComponent implements OnInit {
private readonly documentService = inject(DocumentService) private documentService = inject(DocumentService)
private readonly correspondentService = inject(CorrespondentService) private correspondentService = inject(CorrespondentService)
private readonly storagePathService = inject(StoragePathService) private storagePathService = inject(StoragePathService)
private readonly documentTypeService = inject(DocumentTypeService) private documentTypeService = inject(DocumentTypeService)
private readonly userService = inject(UserService) private userService = inject(UserService)
public AuditLogAction = AuditLogAction public AuditLogAction = AuditLogAction
@@ -70,7 +70,7 @@ export class DocumentHistoryComponent implements OnInit {
return cached return cached
} }
const idInt = Number.parseInt(id, 10) const idInt = parseInt(id, 10)
const fallback$ = of(id) const fallback$ = of(id)
let result$: Observable<string> let result$: Observable<string>
@@ -5,11 +5,10 @@
</button> </button>
<div class="dropdown-menu shadow" ngbDropdownMenu> <div class="dropdown-menu shadow" ngbDropdownMenu>
<div class="px-3 py-2 mb-2"> <div class="px-3 py-2 mb-2">
@if (versionUploadState() === UploadState.Idle) { @if (versionUploadState === UploadState.Idle) {
<div class="input-group input-group-sm mb-2"> <div class="input-group input-group-sm mb-2">
<label class="input-group-text" for="newVersionLabel" i18n>Label</label> <span class="input-group-text" i18n>Label</span>
<input <input
id="newVersionLabel"
class="form-control" class="form-control"
type="text" type="text"
[(ngModel)]="newVersionLabel" [(ngModel)]="newVersionLabel"
@@ -32,7 +31,7 @@
<i-bs name="file-earmark-plus"></i-bs><span class="ps-1" i18n>Add new version</span> <i-bs name="file-earmark-plus"></i-bs><span class="ps-1" i18n>Add new version</span>
</button> </button>
} @else { } @else {
@switch (versionUploadState()) { @switch (versionUploadState) {
@case (UploadState.Uploading) { @case (UploadState.Uploading) {
<div class="small text-muted mt-1 d-flex align-items-center"> <div class="small text-muted mt-1 d-flex align-items-center">
<output class="spinner-border spinner-border-sm me-2" aria-hidden="true"></output> <output class="spinner-border spinner-border-sm me-2" aria-hidden="true"></output>
@@ -50,8 +49,8 @@
<span i18n>Version upload failed.</span> <span i18n>Version upload failed.</span>
<button type="button" class="btn btn-link btn-sm p-0 ms-2" (click)="clearVersionUploadStatus()" i18n>Dismiss</button> <button type="button" class="btn btn-link btn-sm p-0 ms-2" (click)="clearVersionUploadStatus()" i18n>Dismiss</button>
</div> </div>
@if (versionUploadError()) { @if (versionUploadError) {
<div class="small text-muted mt-1">{{ versionUploadError() }}</div> <div class="small text-muted mt-1">{{ versionUploadError }}</div>
} }
} }
} }
@@ -71,19 +70,15 @@
} }
</div> </div>
<div class="d-flex flex-column"> <div class="d-flex flex-column">
@if (isEditingVersion(version.id)) {
<label class="visually-hidden" for="versionLabel{{ version.id }}" i18n>Version label</label>
}
<div class="input-group input-group-sm mb-1"> <div class="input-group input-group-sm mb-1">
@if (isEditingVersion(version.id)) { @if (isEditingVersion(version.id)) {
<input <input
id="versionLabel{{ version.id }}"
class="form-control" class="form-control"
type="text" type="text"
[(ngModel)]="versionLabelDraft" [(ngModel)]="versionLabelDraft"
i18n-placeholder i18n-placeholder
placeholder="Version label" placeholder="Version label"
[disabled]="savingVersionLabelId() !== null" [disabled]="savingVersionLabelId !== null"
(keydown.enter)="submitEditedVersionLabel(version, $event)" (keydown.enter)="submitEditedVersionLabel(version, $event)"
(keydown.escape)="cancelEditingVersion($event)" (keydown.escape)="cancelEditingVersion($event)"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
@@ -101,7 +96,7 @@
<button <button
type="button" type="button"
class="btn btn-outline-secondary" class="btn btn-outline-secondary"
[disabled]="savingVersionLabelId() !== null" [disabled]="savingVersionLabelId !== null"
(click)="isEditingVersion(version.id) ? submitEditedVersionLabel(version, $event) : beginEditingVersion(version, $event)" (click)="isEditingVersion(version.id) ? submitEditedVersionLabel(version, $event) : beginEditingVersion(version, $event)"
> >
@if (isEditingVersion(version.id)) { @if (isEditingVersion(version.id)) {
@@ -205,7 +205,7 @@ describe('DocumentVersionDropdownComponent', () => {
{ id: 3, is_root: true, checksum: 'aaaa' }, { id: 3, is_root: true, checksum: 'aaaa' },
{ id: 10, is_root: false, checksum: 'bbbb', version_label: 'Updated' }, { id: 10, is_root: false, checksum: 'bbbb', version_label: 'Updated' },
]) ])
expect(component.savingVersionLabelId()).toBeNull() expect(component.savingVersionLabelId).toBeNull()
}) })
it('saveVersionLabel should show error toast on failure', () => { it('saveVersionLabel should show error toast on failure', () => {
@@ -218,7 +218,7 @@ describe('DocumentVersionDropdownComponent', () => {
'Error updating version label', 'Error updating version label',
error error
) )
expect(component.savingVersionLabelId()).toBeNull() expect(component.savingVersionLabelId).toBeNull()
}) })
it('onVersionFileSelected should upload and update versions after websocket success', () => { it('onVersionFileSelected should upload and update versions after websocket success', () => {
@@ -252,11 +252,11 @@ describe('DocumentVersionDropdownComponent', () => {
expect(versionsEmitSpy).toHaveBeenCalledWith(versions) expect(versionsEmitSpy).toHaveBeenCalledWith(versions)
expect(selectedEmitSpy).toHaveBeenCalledWith(20) expect(selectedEmitSpy).toHaveBeenCalledWith(20)
expect(component.newVersionLabel).toEqual('') expect(component.newVersionLabel).toEqual('')
expect(component.versionUploadState()).toEqual(UploadState.Idle) expect(component.versionUploadState).toEqual(UploadState.Idle)
expect(component.versionUploadError()).toBeNull() expect(component.versionUploadError).toBeNull()
}) })
it('onVersionFileSelected should render failed state after websocket failure', async () => { it('onVersionFileSelected should set failed state after websocket failure', () => {
const file = new File(['test'], 'new-version.pdf', { const file = new File(['test'], 'new-version.pdf', {
type: 'application/pdf', type: 'application/pdf',
}) })
@@ -266,11 +266,9 @@ describe('DocumentVersionDropdownComponent', () => {
component.onVersionFileSelected({ target: input } as Event) component.onVersionFileSelected({ target: input } as Event)
failed$.next({ taskId: 'task-1', message: 'processing failed' }) failed$.next({ taskId: 'task-1', message: 'processing failed' })
await fixture.whenStable()
expect(component.versionUploadState()).toEqual(UploadState.Failed) expect(component.versionUploadState).toEqual(UploadState.Failed)
expect(component.versionUploadError()).toEqual('processing failed') expect(component.versionUploadError).toEqual('processing failed')
expect(fixture.nativeElement.textContent).toContain('processing failed')
expect(documentService.getVersions).not.toHaveBeenCalled() expect(documentService.getVersions).not.toHaveBeenCalled()
}) })
@@ -284,8 +282,8 @@ describe('DocumentVersionDropdownComponent', () => {
component.onVersionFileSelected({ target: input } as Event) component.onVersionFileSelected({ target: input } as Event)
expect(component.versionUploadState()).toEqual(UploadState.Failed) expect(component.versionUploadState).toEqual(UploadState.Failed)
expect(component.versionUploadError()).toEqual('Missing task ID.') expect(component.versionUploadError).toEqual('Missing task ID.')
expect(documentService.getVersions).not.toHaveBeenCalled() expect(documentService.getVersions).not.toHaveBeenCalled()
}) })
@@ -300,8 +298,8 @@ describe('DocumentVersionDropdownComponent', () => {
component.onVersionFileSelected({ target: input } as Event) component.onVersionFileSelected({ target: input } as Event)
expect(component.versionUploadState()).toEqual(UploadState.Failed) expect(component.versionUploadState).toEqual(UploadState.Failed)
expect(component.versionUploadError()).toEqual('upload failed') expect(component.versionUploadError).toEqual('upload failed')
expect(toastService.showError).toHaveBeenCalledWith( expect(toastService.showError).toHaveBeenCalledWith(
'Error uploading new version', 'Error uploading new version',
error error
@@ -309,8 +307,8 @@ describe('DocumentVersionDropdownComponent', () => {
}) })
it('ngOnChanges should clear upload status on document switch', () => { it('ngOnChanges should clear upload status on document switch', () => {
component.versionUploadState.set(UploadState.Failed) component.versionUploadState = UploadState.Failed
component.versionUploadError.set('something failed') component.versionUploadError = 'something failed'
component.editingVersionId = 10 component.editingVersionId = 10
component.versionLabelDraft = 'draft' component.versionLabelDraft = 'draft'
@@ -318,8 +316,8 @@ describe('DocumentVersionDropdownComponent', () => {
documentId: new SimpleChange(3, 4, false), documentId: new SimpleChange(3, 4, false),
}) })
expect(component.versionUploadState()).toEqual(UploadState.Idle) expect(component.versionUploadState).toEqual(UploadState.Idle)
expect(component.versionUploadError()).toBeNull() expect(component.versionUploadError).toBeNull()
expect(component.editingVersionId).toBeNull() expect(component.editingVersionId).toBeNull()
expect(component.versionLabelDraft).toEqual('') expect(component.versionLabelDraft).toEqual('')
}) })
@@ -7,7 +7,6 @@ import {
OnChanges, OnChanges,
OnDestroy, OnDestroy,
Output, Output,
signal,
SimpleChanges, SimpleChanges,
} from '@angular/core' } from '@angular/core'
import { FormsModule } from '@angular/forms' import { FormsModule } from '@angular/forms'
@@ -60,9 +59,9 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
@Output() versionsUpdated = new EventEmitter<DocumentVersionInfo[]>() @Output() versionsUpdated = new EventEmitter<DocumentVersionInfo[]>()
newVersionLabel: string = '' newVersionLabel: string = ''
readonly versionUploadState = signal(UploadState.Idle) versionUploadState: UploadState = UploadState.Idle
readonly versionUploadError = signal<string | null>(null) versionUploadError: string | null = null
readonly savingVersionLabelId = signal<number | null>(null) savingVersionLabelId: number | null = null
editingVersionId: number | null = null editingVersionId: number | null = null
versionLabelDraft: string = '' versionLabelDraft: string = ''
@@ -102,7 +101,7 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
beginEditingVersion(version: DocumentVersionInfo, event?: Event): void { beginEditingVersion(version: DocumentVersionInfo, event?: Event): void {
event?.preventDefault() event?.preventDefault()
event?.stopPropagation() event?.stopPropagation()
if (!this.canEditLabels || this.savingVersionLabelId() !== null) return if (!this.canEditLabels || this.savingVersionLabelId !== null) return
this.editingVersionId = version.id this.editingVersionId = version.id
this.versionLabelDraft = version.version_label ?? '' this.versionLabelDraft = version.version_label ?? ''
} }
@@ -117,7 +116,7 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
submitEditedVersionLabel(version: DocumentVersionInfo, event?: Event): void { submitEditedVersionLabel(version: DocumentVersionInfo, event?: Event): void {
event?.preventDefault() event?.preventDefault()
event?.stopPropagation() event?.stopPropagation()
if (this.savingVersionLabelId() !== null) return if (this.savingVersionLabelId !== null) return
const nextLabel = this.versionLabelDraft?.trim() || null const nextLabel = this.versionLabelDraft?.trim() || null
const currentLabel = version.version_label?.trim() || null const currentLabel = version.version_label?.trim() || null
if (nextLabel === currentLabel) { if (nextLabel === currentLabel) {
@@ -159,15 +158,15 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
} }
saveVersionLabel(versionId: number, versionLabel: string | null): void { saveVersionLabel(versionId: number, versionLabel: string | null): void {
if (this.savingVersionLabelId() !== null) return if (this.savingVersionLabelId !== null) return
this.savingVersionLabelId.set(versionId) this.savingVersionLabelId = versionId
this.documentsService this.documentsService
.updateVersionLabel(this.documentId, versionId, versionLabel) .updateVersionLabel(this.documentId, versionId, versionLabel)
.pipe( .pipe(
first(), first(),
finalize(() => { finalize(() => {
if (this.savingVersionLabelId() === versionId) { if (this.savingVersionLabelId === versionId) {
this.savingVersionLabelId.set(null) this.savingVersionLabelId = null
} }
}), }),
takeUntil(this.destroy$) takeUntil(this.destroy$)
@@ -200,8 +199,8 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
const file = input.files[0] const file = input.files[0]
input.value = '' input.value = ''
const label = this.newVersionLabel?.trim() const label = this.newVersionLabel?.trim()
this.versionUploadState.set(UploadState.Uploading) this.versionUploadState = UploadState.Uploading
this.versionUploadError.set(null) this.versionUploadError = null
this.documentsService this.documentsService
.uploadVersion(uploadDocumentId, file, label) .uploadVersion(uploadDocumentId, file, label)
.pipe( .pipe(
@@ -211,7 +210,7 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
$localize`Uploading new version. Processing will happen in the background.` $localize`Uploading new version. Processing will happen in the background.`
) )
this.newVersionLabel = '' this.newVersionLabel = ''
this.versionUploadState.set(UploadState.Processing) this.versionUploadState = UploadState.Processing
}), }),
map((taskId) => map((taskId) =>
typeof taskId === 'string' typeof taskId === 'string'
@@ -220,8 +219,8 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
), ),
switchMap((taskId) => { switchMap((taskId) => {
if (!taskId) { if (!taskId) {
this.versionUploadState.set(UploadState.Failed) this.versionUploadState = UploadState.Failed
this.versionUploadError.set($localize`Missing task ID.`) this.versionUploadError = $localize`Missing task ID.`
return of(null) return of(null)
} }
return merge( return merge(
@@ -241,10 +240,9 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
switchMap((result) => { switchMap((result) => {
if (result?.state !== 'success') { if (result?.state !== 'success') {
if (result?.state === 'failed') { if (result?.state === 'failed') {
this.versionUploadState.set(UploadState.Failed) this.versionUploadState = UploadState.Failed
this.versionUploadError.set( this.versionUploadError =
result.message || $localize`Upload failed.` result.message || $localize`Upload failed.`
)
} }
return of(null) return of(null)
} }
@@ -266,10 +264,8 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
}, },
error: (error) => { error: (error) => {
if (uploadDocumentId !== this.documentId) return if (uploadDocumentId !== this.documentId) return
this.versionUploadState.set(UploadState.Failed) this.versionUploadState = UploadState.Failed
this.versionUploadError.set( this.versionUploadError = error?.message || $localize`Upload failed.`
error?.message || $localize`Upload failed.`
)
this.toastService.showError( this.toastService.showError(
$localize`Error uploading new version`, $localize`Error uploading new version`,
error error
@@ -279,7 +275,7 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
} }
clearVersionUploadStatus(): void { clearVersionUploadStatus(): void {
this.versionUploadState.set(UploadState.Idle) this.versionUploadState = UploadState.Idle
this.versionUploadError.set(null) this.versionUploadError = null
} }
} }
@@ -10,7 +10,7 @@
[createRef]="createTag.bind(this)" [createRef]="createTag.bind(this)"
(opened)="openTagsDropdown()" (opened)="openTagsDropdown()"
[(selectionModel)]="tagSelectionModel" [(selectionModel)]="tagSelectionModel"
[documentCounts]="tagDocumentCounts()" [documentCounts]="tagDocumentCounts"
(apply)="setTags($event)" (apply)="setTags($event)"
shortcutKey="t"> shortcutKey="t">
</pngx-filterable-dropdown> </pngx-filterable-dropdown>
@@ -24,7 +24,7 @@
[createRef]="createCorrespondent.bind(this)" [createRef]="createCorrespondent.bind(this)"
(opened)="openCorrespondentDropdown()" (opened)="openCorrespondentDropdown()"
[(selectionModel)]="correspondentSelectionModel" [(selectionModel)]="correspondentSelectionModel"
[documentCounts]="correspondentDocumentCounts()" [documentCounts]="correspondentDocumentCounts"
(apply)="setCorrespondents($event)" (apply)="setCorrespondents($event)"
shortcutKey="y"> shortcutKey="y">
</pngx-filterable-dropdown> </pngx-filterable-dropdown>
@@ -38,7 +38,7 @@
[createRef]="createDocumentType.bind(this)" [createRef]="createDocumentType.bind(this)"
(opened)="openDocumentTypeDropdown()" (opened)="openDocumentTypeDropdown()"
[(selectionModel)]="documentTypeSelectionModel" [(selectionModel)]="documentTypeSelectionModel"
[documentCounts]="documentTypeDocumentCounts()" [documentCounts]="documentTypeDocumentCounts"
(apply)="setDocumentTypes($event)" (apply)="setDocumentTypes($event)"
shortcutKey="u"> shortcutKey="u">
</pngx-filterable-dropdown> </pngx-filterable-dropdown>
@@ -52,7 +52,7 @@
[createRef]="createStoragePath.bind(this)" [createRef]="createStoragePath.bind(this)"
(opened)="openStoragePathDropdown()" (opened)="openStoragePathDropdown()"
[(selectionModel)]="storagePathsSelectionModel" [(selectionModel)]="storagePathsSelectionModel"
[documentCounts]="storagePathDocumentCounts()" [documentCounts]="storagePathDocumentCounts"
(apply)="setStoragePaths($event)" (apply)="setStoragePaths($event)"
shortcutKey="i"> shortcutKey="i">
</pngx-filterable-dropdown> </pngx-filterable-dropdown>
@@ -66,7 +66,7 @@
[createRef]="createCustomField.bind(this)" [createRef]="createCustomField.bind(this)"
(opened)="openCustomFieldsDropdown()" (opened)="openCustomFieldsDropdown()"
[(selectionModel)]="customFieldsSelectionModel" [(selectionModel)]="customFieldsSelectionModel"
[documentCounts]="customFieldDocumentCounts()" [documentCounts]="customFieldDocumentCounts"
extraButtonTitle="Set values" extraButtonTitle="Set values"
i18n-extraButtonTitle i18n-extraButtonTitle
(extraButton)="setCustomFieldValues($event)" (extraButton)="setCustomFieldValues($event)"
@@ -103,13 +103,13 @@
class="btn btn-sm btn-outline-primary" class="btn btn-sm btn-outline-primary"
id="dropdownSend" id="dropdownSend"
ngbDropdownToggle ngbDropdownToggle
[disabled]="disabled || !canSendSelection" [disabled]="disabled || !list.hasSelection || list.allSelected"
> >
<i-bs name="send"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Send</ng-container> <i-bs name="send"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Send</ng-container>
</div> </div>
</button> </button>
<div ngbDropdownMenu aria-labelledby="dropdownSend" class="shadow"> <div ngbDropdownMenu aria-labelledby="dropdownSend" class="shadow">
<button ngbDropdownItem (click)="createShareLinkBundle()" [disabled]="!canSendSelection"> <button ngbDropdownItem (click)="createShareLinkBundle()" [disabled]="list.allSelected">
<i-bs name="link" class="me-1"></i-bs><ng-container i18n>Create a share link bundle</ng-container> <i-bs name="link" class="me-1"></i-bs><ng-container i18n>Create a share link bundle</ng-container>
</button> </button>
<button ngbDropdownItem (click)="manageShareLinkBundles()"> <button ngbDropdownItem (click)="manageShareLinkBundles()">
@@ -117,18 +117,18 @@
</button> </button>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
@if (emailEnabled) { @if (emailEnabled) {
<button ngbDropdownItem (click)="emailSelected()" [disabled]="!canSendSelection"> <button ngbDropdownItem (click)="emailSelected()" [disabled]="list.allSelected">
<i-bs name="envelope" class="me-1"></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>
</div> </div>
<div class="btn-group btn-group-sm"> <div class="btn-group btn-group-sm">
<button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload()" (click)="downloadSelected()"> <button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload" (click)="downloadSelected()">
@if (!awaitingDownload()) { @if (!awaitingDownload) {
<i-bs name="arrow-down"></i-bs> <i-bs name="arrow-down"></i-bs>
} }
@if (awaitingDownload()) { @if (awaitingDownload) {
<div class="spinner-border spinner-border-sm" role="status"> <div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Preparing download...</span> <span class="visually-hidden">Preparing download...</span>
</div> </div>
@@ -208,50 +208,6 @@ describe('BulkEditorComponent', () => {
expect(component.tagSelectionModel.selectionSize()).toEqual(1) expect(component.tagSelectionModel.selectionSize()).toEqual(1)
}) })
it('should allow sending an all-filtered selection that fits on the current page', () => {
jest
.spyOn(documentListViewService, 'hasSelection', 'get')
.mockReturnValue(true)
jest
.spyOn(documentListViewService, 'allSelected', 'get')
.mockReturnValue(true)
jest
.spyOn(documentListViewService, 'selectedCount', 'get')
.mockReturnValue(5)
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([1, 2, 3, 4, 5]))
fixture.detectChanges()
expect(component.canSendSelection).toBe(true)
expect(
fixture.debugElement.query(By.css('#dropdownSend')).nativeElement.disabled
).toBe(false)
})
it('should prevent sending an all-filtered selection spanning multiple pages', () => {
jest
.spyOn(documentListViewService, 'hasSelection', 'get')
.mockReturnValue(true)
jest
.spyOn(documentListViewService, 'allSelected', 'get')
.mockReturnValue(true)
jest
.spyOn(documentListViewService, 'selectedCount', 'get')
.mockReturnValue(6)
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([1, 2, 3, 4, 5]))
fixture.detectChanges()
expect(component.canSendSelection).toBe(false)
expect(
fixture.debugElement.query(By.css('#dropdownSend')).nativeElement.disabled
).toBe(true)
})
it('should apply selection data to correspondents menu', () => { it('should apply selection data to correspondents menu', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
fixture.detectChanges() fixture.detectChanges()
@@ -347,7 +303,7 @@ describe('BulkEditorComponent', () => {
component.openDocumentTypeDropdown() component.openDocumentTypeDropdown()
expect(getSelectionDataSpy).not.toHaveBeenCalled() expect(getSelectionDataSpy).not.toHaveBeenCalled()
expect(component.documentTypeDocumentCounts()).toEqual( expect(component.documentTypeDocumentCounts).toEqual(
selectionData.selected_document_types selectionData.selected_document_types
) )
}) })
@@ -364,7 +320,7 @@ describe('BulkEditorComponent', () => {
component.openCorrespondentDropdown() component.openCorrespondentDropdown()
expect(getSelectionDataSpy).not.toHaveBeenCalled() expect(getSelectionDataSpy).not.toHaveBeenCalled()
expect(component.correspondentDocumentCounts()).toEqual( expect(component.correspondentDocumentCounts).toEqual(
selectionData.selected_correspondents selectionData.selected_correspondents
) )
}) })
@@ -381,7 +337,7 @@ describe('BulkEditorComponent', () => {
component.openStoragePathDropdown() component.openStoragePathDropdown()
expect(getSelectionDataSpy).not.toHaveBeenCalled() expect(getSelectionDataSpy).not.toHaveBeenCalled()
expect(component.storagePathDocumentCounts()).toEqual( expect(component.storagePathDocumentCounts).toEqual(
selectionData.selected_storage_paths selectionData.selected_storage_paths
) )
}) })
@@ -398,7 +354,7 @@ describe('BulkEditorComponent', () => {
component.openCustomFieldsDropdown() component.openCustomFieldsDropdown()
expect(getSelectionDataSpy).not.toHaveBeenCalled() expect(getSelectionDataSpy).not.toHaveBeenCalled()
expect(component.customFieldDocumentCounts()).toEqual( expect(component.customFieldDocumentCounts).toEqual(
selectionData.selected_custom_fields selectionData.selected_custom_fields
) )
}) })
@@ -1641,7 +1597,6 @@ describe('BulkEditorComponent', () => {
expect(modal.componentInstance.customFields.length).toEqual(2) expect(modal.componentInstance.customFields.length).toEqual(2)
expect(modal.componentInstance.fieldsToAddIds).toEqual([1, 2]) expect(modal.componentInstance.fieldsToAddIds).toEqual([1, 2])
expect(modal.componentInstance.selection).toEqual({ documents: [3, 4] }) expect(modal.componentInstance.selection).toEqual({ documents: [3, 4] })
expect(modal.componentInstance.selectionCount).toEqual(2)
expect(modal.componentInstance.documents).toEqual([3, 4]) expect(modal.componentInstance.documents).toEqual([3, 4])
modal.componentInstance.failed.emit() modal.componentInstance.failed.emit()
@@ -1,11 +1,4 @@
import { import { Component, inject, Input, OnDestroy, OnInit } from '@angular/core'
Component,
inject,
Input,
OnDestroy,
OnInit,
signal,
} from '@angular/core'
import { import {
FormControl, FormControl,
FormGroup, FormGroup,
@@ -108,12 +101,12 @@ export class BulkEditorComponent
documentTypeSelectionModel = new FilterableDropdownSelectionModel() documentTypeSelectionModel = new FilterableDropdownSelectionModel()
storagePathsSelectionModel = new FilterableDropdownSelectionModel() storagePathsSelectionModel = new FilterableDropdownSelectionModel()
customFieldsSelectionModel = new FilterableDropdownSelectionModel(true) customFieldsSelectionModel = new FilterableDropdownSelectionModel(true)
readonly tagDocumentCounts = signal<SelectionDataItem[]>(undefined) tagDocumentCounts: SelectionDataItem[]
readonly correspondentDocumentCounts = signal<SelectionDataItem[]>(undefined) correspondentDocumentCounts: SelectionDataItem[]
readonly documentTypeDocumentCounts = signal<SelectionDataItem[]>(undefined) documentTypeDocumentCounts: SelectionDataItem[]
readonly storagePathDocumentCounts = signal<SelectionDataItem[]>(undefined) storagePathDocumentCounts: SelectionDataItem[]
readonly customFieldDocumentCounts = signal<SelectionDataItem[]>(undefined) customFieldDocumentCounts: SelectionDataItem[]
readonly awaitingDownload = signal(false) awaitingDownload: boolean
unsubscribeNotifier: Subject<any> = new Subject() unsubscribeNotifier: Subject<any> = new Subject()
@@ -372,8 +365,8 @@ export class BulkEditorComponent
openTagsDropdown() { openTagsDropdown() {
if (this.list.allSelected) { if (this.list.allSelected) {
const selectionData = this.list.selectionData const selectionData = this.list.selectionData
this.tagDocumentCounts.set(selectionData?.selected_tags ?? []) this.tagDocumentCounts = selectionData?.selected_tags ?? []
this.applySelectionData(this.tagDocumentCounts(), this.tagSelectionModel) this.applySelectionData(this.tagDocumentCounts, this.tagSelectionModel)
return return
} }
@@ -381,7 +374,7 @@ export class BulkEditorComponent
.getSelectionData(Array.from(this.list.selected)) .getSelectionData(Array.from(this.list.selected))
.pipe(first()) .pipe(first())
.subscribe((s) => { .subscribe((s) => {
this.tagDocumentCounts.set(s.selected_tags) this.tagDocumentCounts = s.selected_tags
this.applySelectionData(s.selected_tags, this.tagSelectionModel) this.applySelectionData(s.selected_tags, this.tagSelectionModel)
}) })
} }
@@ -389,11 +382,10 @@ export class BulkEditorComponent
openDocumentTypeDropdown() { openDocumentTypeDropdown() {
if (this.list.allSelected) { if (this.list.allSelected) {
const selectionData = this.list.selectionData const selectionData = this.list.selectionData
this.documentTypeDocumentCounts.set( this.documentTypeDocumentCounts =
selectionData?.selected_document_types ?? [] selectionData?.selected_document_types ?? []
)
this.applySelectionData( this.applySelectionData(
this.documentTypeDocumentCounts(), this.documentTypeDocumentCounts,
this.documentTypeSelectionModel this.documentTypeSelectionModel
) )
return return
@@ -403,7 +395,7 @@ export class BulkEditorComponent
.getSelectionData(Array.from(this.list.selected)) .getSelectionData(Array.from(this.list.selected))
.pipe(first()) .pipe(first())
.subscribe((s) => { .subscribe((s) => {
this.documentTypeDocumentCounts.set(s.selected_document_types) this.documentTypeDocumentCounts = s.selected_document_types
this.applySelectionData( this.applySelectionData(
s.selected_document_types, s.selected_document_types,
this.documentTypeSelectionModel this.documentTypeSelectionModel
@@ -414,11 +406,10 @@ export class BulkEditorComponent
openCorrespondentDropdown() { openCorrespondentDropdown() {
if (this.list.allSelected) { if (this.list.allSelected) {
const selectionData = this.list.selectionData const selectionData = this.list.selectionData
this.correspondentDocumentCounts.set( this.correspondentDocumentCounts =
selectionData?.selected_correspondents ?? [] selectionData?.selected_correspondents ?? []
)
this.applySelectionData( this.applySelectionData(
this.correspondentDocumentCounts(), this.correspondentDocumentCounts,
this.correspondentSelectionModel this.correspondentSelectionModel
) )
return return
@@ -428,7 +419,7 @@ export class BulkEditorComponent
.getSelectionData(Array.from(this.list.selected)) .getSelectionData(Array.from(this.list.selected))
.pipe(first()) .pipe(first())
.subscribe((s) => { .subscribe((s) => {
this.correspondentDocumentCounts.set(s.selected_correspondents) this.correspondentDocumentCounts = s.selected_correspondents
this.applySelectionData( this.applySelectionData(
s.selected_correspondents, s.selected_correspondents,
this.correspondentSelectionModel this.correspondentSelectionModel
@@ -439,11 +430,10 @@ export class BulkEditorComponent
openStoragePathDropdown() { openStoragePathDropdown() {
if (this.list.allSelected) { if (this.list.allSelected) {
const selectionData = this.list.selectionData const selectionData = this.list.selectionData
this.storagePathDocumentCounts.set( this.storagePathDocumentCounts =
selectionData?.selected_storage_paths ?? [] selectionData?.selected_storage_paths ?? []
)
this.applySelectionData( this.applySelectionData(
this.storagePathDocumentCounts(), this.storagePathDocumentCounts,
this.storagePathsSelectionModel this.storagePathsSelectionModel
) )
return return
@@ -453,7 +443,7 @@ export class BulkEditorComponent
.getSelectionData(Array.from(this.list.selected)) .getSelectionData(Array.from(this.list.selected))
.pipe(first()) .pipe(first())
.subscribe((s) => { .subscribe((s) => {
this.storagePathDocumentCounts.set(s.selected_storage_paths) this.storagePathDocumentCounts = s.selected_storage_paths
this.applySelectionData( this.applySelectionData(
s.selected_storage_paths, s.selected_storage_paths,
this.storagePathsSelectionModel this.storagePathsSelectionModel
@@ -464,11 +454,10 @@ export class BulkEditorComponent
openCustomFieldsDropdown() { openCustomFieldsDropdown() {
if (this.list.allSelected) { if (this.list.allSelected) {
const selectionData = this.list.selectionData const selectionData = this.list.selectionData
this.customFieldDocumentCounts.set( this.customFieldDocumentCounts =
selectionData?.selected_custom_fields ?? [] selectionData?.selected_custom_fields ?? []
)
this.applySelectionData( this.applySelectionData(
this.customFieldDocumentCounts(), this.customFieldDocumentCounts,
this.customFieldsSelectionModel this.customFieldsSelectionModel
) )
return return
@@ -478,7 +467,7 @@ export class BulkEditorComponent
.getSelectionData(Array.from(this.list.selected)) .getSelectionData(Array.from(this.list.selected))
.pipe(first()) .pipe(first())
.subscribe((s) => { .subscribe((s) => {
this.customFieldDocumentCounts.set(s.selected_custom_fields) this.customFieldDocumentCounts = s.selected_custom_fields
this.applySelectionData( this.applySelectionData(
s.selected_custom_fields, s.selected_custom_fields,
this.customFieldsSelectionModel this.customFieldsSelectionModel
@@ -887,7 +876,7 @@ export class BulkEditorComponent
} }
downloadSelected() { downloadSelected() {
this.awaitingDownload.set(true) this.awaitingDownload = true
let downloadFileType: string = let downloadFileType: string =
this.downloadForm.get('downloadFileTypeArchive').value && this.downloadForm.get('downloadFileTypeArchive').value &&
this.downloadForm.get('downloadFileTypeOriginals').value this.downloadForm.get('downloadFileTypeOriginals').value
@@ -904,7 +893,7 @@ export class BulkEditorComponent
.pipe(first()) .pipe(first())
.subscribe((result: any) => { .subscribe((result: any) => {
saveAs(result, 'documents.zip') saveAs(result, 'documents.zip')
this.awaitingDownload.set(false) this.awaitingDownload = false
}) })
} }
@@ -1020,7 +1009,6 @@ export class BulkEditorComponent
) )
dialog.selection = this.getSelectionQuery() dialog.selection = this.getSelectionQuery()
dialog.selectionCount = this.getSelectionSize()
dialog.succeeded.subscribe((result) => { dialog.succeeded.subscribe((result) => {
this.toastService.showInfo($localize`Custom fields updated.`) this.toastService.showInfo($localize`Custom fields updated.`)
this.list.reload() this.list.reload()
@@ -1041,14 +1029,6 @@ export class BulkEditorComponent
return this.settings.get(SETTINGS_KEYS.EMAIL_ENABLED) return this.settings.get(SETTINGS_KEYS.EMAIL_ENABLED)
} }
public get canSendSelection(): boolean {
return (
this.list.hasSelection &&
(!this.list.allSelected ||
this.list.selectedCount === this.list.selected.size)
)
}
createShareLinkBundle() { createShareLinkBundle() {
const modal = this.modalService.open(ShareLinkBundleDialogComponent, { const modal = this.modalService.open(ShareLinkBundleDialogComponent, {
backdrop: 'static', backdrop: 'static',
@@ -1,9 +1,9 @@
<form [formGroup]="form" (ngSubmit)="save()" autocomplete="off"> <form [formGroup]="form" (ngSubmit)="save()" autocomplete="off">
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title" id="modal-basic-title" i8n>{ <h4 class="modal-title" id="modal-basic-title" i8n>{
documentCount, documents.length,
plural, plural,
=1 {Set custom fields for 1 document} other {Set custom fields for {{documentCount}} documents} =1 {Set custom fields for 1 document} other {Set custom fields for {{documents.length}} documents}
}</h4> }</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()"> <button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
</button> </button>
@@ -42,20 +42,6 @@ describe('CustomFieldsBulkEditDialogComponent', () => {
expect(component.form.contains('2')).toBeTruthy() expect(component.form.contains('2')).toBeTruthy()
}) })
it('should render the document count for a filtered selection', () => {
component.selection = {
all: true,
filters: { title__icontains: 'invoice' },
}
component.selectionCount = 42
fixture.detectChanges()
expect(component.documents).toEqual([])
expect(
fixture.nativeElement.querySelector('.modal-title').textContent
).toContain('Set custom fields for 42 documents')
})
it('should emit succeeded event and close modal on successful save', () => { it('should emit succeeded event and close modal on successful save', () => {
const editSpy = jest const editSpy = jest
.spyOn(documentService, 'bulkEdit') .spyOn(documentService, 'bulkEdit')
@@ -81,14 +81,8 @@ export class CustomFieldsBulkEditDialogComponent {
public selection: DocumentSelectionQuery = { documents: [] } public selection: DocumentSelectionQuery = { documents: [] }
public selectionCount: number
public get documents(): number[] { public get documents(): number[] {
return this.selection.documents ?? [] return this.selection.documents
}
public get documentCount(): number {
return this.selectionCount ?? this.documents.length
} }
initForm() { initForm() {
@@ -2,11 +2,7 @@
<div class="row g-0"> <div class="row g-0">
<div class="col-md-2 doc-img-container rounded-start" (click)="this.toggleSelected.emit($event)" (dblclick)="dblClickDocument.emit()"> <div class="col-md-2 doc-img-container rounded-start" (click)="this.toggleSelected.emit($event)" (dblclick)="dblClickDocument.emit()">
@if (document()) { @if (document()) {
@if (priority()) { <img [src]="getThumbUrl()" class="card-img doc-img border-end rounded-start" [class.inverted]="getIsThumbInverted()">
<img [ngSrc]="getThumbUrl()" fill priority class="card-img doc-img border-end rounded-start" [class.inverted]="getIsThumbInverted()" alt="{{ document().title | documentTitle }} thumbnail" i18n-alt>
} @else {
<img [ngSrc]="getThumbUrl()" fill class="card-img doc-img border-end rounded-start" [class.inverted]="getIsThumbInverted()" alt="{{ document().title | documentTitle }} thumbnail" i18n-alt>
}
<div class="border-end border-bottom bg-light document-card-check"> <div class="border-end border-bottom bg-light document-card-check">
<div class="form-check"> <div class="form-check">
@@ -88,21 +88,6 @@ describe('DocumentCardLargeComponent', () => {
expect(fixture.nativeElement.textContent).toContain('8 pages') expect(fixture.nativeElement.textContent).toContain('8 pages')
}) })
it('should lazy load the thumbnail', () => {
const thumbnail: HTMLImageElement =
fixture.nativeElement.querySelector('img.doc-img')
expect(thumbnail.getAttribute('loading')).toEqual('lazy')
})
it('should prioritize the thumbnail when requested', () => {
fixture.componentRef.setInput('priority', true)
fixture.detectChanges()
const thumbnail: HTMLImageElement =
fixture.nativeElement.querySelector('img.doc-img')
expect(thumbnail.getAttribute('loading')).toEqual('eager')
expect(thumbnail.getAttribute('fetchpriority')).toEqual('high')
})
it('should trim content', () => { it('should trim content', () => {
expect(component.contentTrimmed).toHaveLength(503) // includes ... expect(component.contentTrimmed).toHaveLength(503) // includes ...
}) })
@@ -1,4 +1,4 @@
import { AsyncPipe, NgOptimizedImage } from '@angular/common' import { AsyncPipe } from '@angular/common'
import { import {
AfterViewInit, AfterViewInit,
Component, Component,
@@ -46,7 +46,6 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
TagComponent, TagComponent,
CustomFieldDisplayComponent, CustomFieldDisplayComponent,
AsyncPipe, AsyncPipe,
NgOptimizedImage,
UsernamePipe, UsernamePipe,
CorrespondentNamePipe, CorrespondentNamePipe,
DocumentTypeNamePipe, DocumentTypeNamePipe,
@@ -66,7 +65,6 @@ export class DocumentCardLargeComponent
private documentService = inject(DocumentService) private documentService = inject(DocumentService)
settingsService = inject(SettingsService) settingsService = inject(SettingsService)
readonly selected = input(false) readonly selected = input(false)
readonly priority = input(false)
readonly displayFields = input<string[]>( readonly displayFields = input<string[]>(
DEFAULT_DISPLAY_FIELDS.map((f) => f.id) DEFAULT_DISPLAY_FIELDS.map((f) => f.id)
) )
@@ -2,11 +2,7 @@
<div class="card h-100 shadow-sm document-card" [class.placeholder-glow]="!document()" [class.card-selected]="selected()" (mouseleave)="mouseLeaveCard()"> <div class="card h-100 shadow-sm document-card" [class.placeholder-glow]="!document()" [class.card-selected]="selected()" (mouseleave)="mouseLeaveCard()">
<div class="border-bottom doc-img-container rounded-top" (click)="this.toggleSelected.emit($event)" (dblclick)="dblClickDocument.emit(this)"> <div class="border-bottom doc-img-container rounded-top" (click)="this.toggleSelected.emit($event)" (dblclick)="dblClickDocument.emit(this)">
@if (document()) { @if (document()) {
@if (priority()) { <img class="card-img doc-img" [class.inverted]="getIsThumbInverted()" [src]="getThumbUrl()">
<img class="card-img doc-img" [class.inverted]="getIsThumbInverted()" [ngSrc]="getThumbUrl()" fill priority alt="{{ document().title | documentTitle }} thumbnail" i18n-alt>
} @else {
<img class="card-img doc-img" [class.inverted]="getIsThumbInverted()" [ngSrc]="getThumbUrl()" fill alt="{{ document().title | documentTitle }} thumbnail" i18n-alt>
}
<div class="border-end border-bottom bg-light py-1 px-2 document-card-check"> <div class="border-end border-bottom bg-light py-1 px-2 document-card-check">
<div class="form-check"> <div class="form-check">
@@ -22,14 +22,10 @@
} }
} }
.doc-img-container {
position: relative;
height: 180px;
}
.doc-img { .doc-img {
object-fit: cover; object-fit: cover;
object-position: top left; object-position: top left;
height: 180px;
} }
.document-card-check { .document-card-check {
@@ -61,21 +61,6 @@ describe('DocumentCardSmallComponent', () => {
expect(fixture.nativeElement.textContent).toContain('12 pages') expect(fixture.nativeElement.textContent).toContain('12 pages')
}) })
it('should lazy load the thumbnail', () => {
const thumbnail: HTMLImageElement =
fixture.nativeElement.querySelector('img.doc-img')
expect(thumbnail.getAttribute('loading')).toEqual('lazy')
})
it('should prioritize the thumbnail when requested', () => {
fixture.componentRef.setInput('priority', true)
fixture.detectChanges()
const thumbnail: HTMLImageElement =
fixture.nativeElement.querySelector('img.doc-img')
expect(thumbnail.getAttribute('loading')).toEqual('eager')
expect(thumbnail.getAttribute('fetchpriority')).toEqual('high')
})
it('should display a document, limit tags to 5', () => { it('should display a document, limit tags to 5', () => {
expect(fixture.nativeElement.textContent).toContain('Document 10') expect(fixture.nativeElement.textContent).toContain('Document 10')
expect( expect(
@@ -1,4 +1,4 @@
import { AsyncPipe, NgOptimizedImage } from '@angular/common' import { AsyncPipe } from '@angular/common'
import { import {
AfterViewInit, AfterViewInit,
Component, Component,
@@ -46,7 +46,6 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
TagComponent, TagComponent,
CustomFieldDisplayComponent, CustomFieldDisplayComponent,
AsyncPipe, AsyncPipe,
NgOptimizedImage,
UsernamePipe, UsernamePipe,
CorrespondentNamePipe, CorrespondentNamePipe,
DocumentTypeNamePipe, DocumentTypeNamePipe,
@@ -66,7 +65,6 @@ export class DocumentCardSmallComponent
private documentService = inject(DocumentService) private documentService = inject(DocumentService)
settingsService = inject(SettingsService) settingsService = inject(SettingsService)
readonly selected = input(false) readonly selected = input(false)
readonly priority = input(false)
readonly document = input<Document>(undefined) readonly document = input<Document>(undefined)
readonly displayFields = input<string[]>( readonly displayFields = input<string[]>(
DEFAULT_DISPLAY_FIELDS.map((f) => f.id) DEFAULT_DISPLAY_FIELDS.map((f) => f.id)
@@ -115,7 +115,7 @@
</pngx-page-header> </pngx-page-header>
<div class="row sticky-top py-3 mt-n2 mt-md-n3 bg-body"> <div class="row sticky-top py-3 mt-n2 mt-md-n3 bg-body">
<pngx-filter-editor [hidden]="isBulkEditing" [disabled]="isBulkEditing" [filterRules]="list.filterRules" (filterRulesChange)="onFilterRulesChange($event)" (resetFilterRules)="onFilterRulesReset($event)" [unmodifiedFilterRules]="unmodifiedFilterRules()" [selectionData]="list.selectionData" #filterEditor></pngx-filter-editor> <pngx-filter-editor [hidden]="isBulkEditing" [disabled]="isBulkEditing" [filterRules]="list.filterRules" (filterRulesChange)="onFilterRulesChange($event)" (resetFilterRules)="onFilterRulesReset($event)" [unmodifiedFilterRules]="unmodifiedFilterRules" [selectionData]="list.selectionData" #filterEditor></pngx-filter-editor>
<pngx-bulk-editor [hidden]="!isBulkEditing" [disabled]="!isBulkEditing"></pngx-bulk-editor> <pngx-bulk-editor [hidden]="!isBulkEditing" [disabled]="!isBulkEditing"></pngx-bulk-editor>
</div> </div>
@@ -164,10 +164,9 @@
} @else { } @else {
@if (list.displayMode === DisplayMode.LARGE_CARDS) { @if (list.displayMode === DisplayMode.LARGE_CARDS) {
<div> <div>
@for (d of list.documents; track d.id; let i = $index) { @for (d of list.documents; track d.id) {
<pngx-document-card-large <pngx-document-card-large
[selected]="list.isSelected(d)" [selected]="list.isSelected(d)"
[priority]="i < 2"
(toggleSelected)="toggleSelected(d, $event)" (toggleSelected)="toggleSelected(d, $event)"
(dblClickDocument)="openDocumentDetail(d)" (dblClickDocument)="openDocumentDetail(d)"
[document]="d" [document]="d"
@@ -399,10 +398,9 @@
} }
@if (list.displayMode === DisplayMode.SMALL_CARDS) { @if (list.displayMode === DisplayMode.SMALL_CARDS) {
<div class="row row-cols-paperless-cards"> <div class="row row-cols-paperless-cards">
@for (d of list.documents; track d.id; let i = $index) { @for (d of list.documents; track d.id) {
<pngx-document-card-small class="p-0" <pngx-document-card-small class="p-0"
[selected]="list.isSelected(d)" [selected]="list.isSelected(d)"
[priority]="i < 6"
(toggleSelected)="toggleSelected(d, $event)" (toggleSelected)="toggleSelected(d, $event)"
(dblClickDocument)="openDocumentDetail(d)" (dblClickDocument)="openDocumentDetail(d)"
[document]="d" [document]="d"
@@ -5,7 +5,6 @@ import {
OnDestroy, OnDestroy,
OnInit, OnInit,
QueryList, QueryList,
signal,
ViewChild, ViewChild,
ViewChildren, ViewChildren,
} from '@angular/core' } from '@angular/core'
@@ -150,7 +149,7 @@ export class DocumentListComponent
) )
} }
readonly unmodifiedFilterRules = signal<FilterRule[]>([]) unmodifiedFilterRules: FilterRule[] = []
private unmodifiedSavedView: SavedView private unmodifiedSavedView: SavedView
private activeSavedView: SavedView | null = null private activeSavedView: SavedView | null = null
@@ -300,7 +299,7 @@ export class DocumentListComponent
this.savedViewService.setDocumentCount(view, this.list.collectionSize) this.savedViewService.setDocumentCount(view, this.list.collectionSize)
}) })
this.updateDisplayCustomFields() this.updateDisplayCustomFields()
this.unmodifiedFilterRules.set(view.filter_rules) this.unmodifiedFilterRules = view.filter_rules
}) })
this.route.queryParamMap this.route.queryParamMap
@@ -317,7 +316,7 @@ export class DocumentListComponent
this.activeSavedView = null this.activeSavedView = null
this.list.activateSavedView(null) this.list.activateSavedView(null)
this.list.loadFromQueryParams(queryParams) this.list.loadFromQueryParams(queryParams)
this.unmodifiedFilterRules.set([]) this.unmodifiedFilterRules = []
} }
}) })
@@ -416,7 +415,7 @@ export class DocumentListComponent
this.toastService.showInfo( 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 = this.list.filterRules
}, },
error: (err) => { error: (err) => {
this.toastService.showError( this.toastService.showError(
@@ -80,7 +80,7 @@
shortcutKey="i"></pngx-filterable-dropdown> shortcutKey="i"></pngx-filterable-dropdown>
} }
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.CustomField) && customFields().length > 0) { @if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.CustomField) && customFields.length > 0) {
<pngx-custom-fields-query-dropdown class="flex-fill fade" [class.show]="show()" title="Custom fields" icon="ui-radios" i18n-title <pngx-custom-fields-query-dropdown class="flex-fill fade" [class.show]="show()" title="Custom fields" icon="ui-radios" i18n-title
[(selectionModel)]="customFieldQueriesModel" [(selectionModel)]="customFieldQueriesModel"
(selectionModelChange)="updateRules()" (selectionModelChange)="updateRules()"
@@ -328,7 +328,7 @@ describe('FilterEditorComponent', () => {
}) })
it('should ingest legacy text filter rules for doc title + content', () => { it('should ingest legacy text filter rules for doc title + content', () => {
expect(component.textFilter).toBeNull() expect(component.textFilter).toEqual(null)
component.filterRules = [ component.filterRules = [
{ {
rule_type: FILTER_TITLE_CONTENT, rule_type: FILTER_TITLE_CONTENT,
@@ -1,6 +1,5 @@
import { import {
AfterViewInit, AfterViewInit,
ChangeDetectorRef,
Component, Component,
ElementRef, ElementRef,
EventEmitter, EventEmitter,
@@ -10,7 +9,6 @@ import {
Output, Output,
ViewChild, ViewChild,
inject, inject,
signal,
} from '@angular/core' } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { import {
@@ -351,7 +349,7 @@ export class FilterEditorComponent
@ViewChild('textFilterInput') @ViewChild('textFilterInput')
textFilterInput: ElementRef textFilterInput: ElementRef
readonly customFields = signal<CustomField[]>([]) customFields: CustomField[] = []
tagDocumentCounts: SelectionDataItem[] tagDocumentCounts: SelectionDataItem[]
correspondentDocumentCounts: SelectionDataItem[] correspondentDocumentCounts: SelectionDataItem[]
@@ -516,7 +514,6 @@ export class FilterEditorComponent
this.documentService.get(this._moreLikeId).subscribe((result) => { this.documentService.get(this._moreLikeId).subscribe((result) => {
this._moreLikeDoc = result this._moreLikeDoc = result
this._textFilter = result.title this._textFilter = result.title
this.changeDetector.markForCheck()
}) })
break break
case FILTER_CREATED_AFTER: case FILTER_CREATED_AFTER:
@@ -1165,7 +1162,6 @@ export class FilterEditorComponent
private loadingCountTotal: number = 0 private loadingCountTotal: number = 0
private loadingCount: number = 0 private loadingCount: number = 0
private readonly changeDetector = inject(ChangeDetectorRef)
private maybeCompleteLoading() { private maybeCompleteLoading() {
this.loadingCount++ this.loadingCount++
@@ -1233,7 +1229,7 @@ export class FilterEditorComponent
) { ) {
this.loadingCountTotal++ this.loadingCountTotal++
this.customFieldService.listAll().subscribe((result) => { this.customFieldService.listAll().subscribe((result) => {
this.customFields.set(result.results) this.customFields = result.results
this.maybeCompleteLoading() this.maybeCompleteLoading()
}) })
} }
@@ -18,7 +18,6 @@ import {
DocumentAttributesComponent, DocumentAttributesComponent,
DocumentAttributesSectionKind, DocumentAttributesSectionKind,
} from './document-attributes.component' } from './document-attributes.component'
import { ManagementListComponent } from './management-list/management-list.component'
@Component({ @Component({
selector: 'pngx-dummy-section', selector: 'pngx-dummy-section',
@@ -171,32 +170,10 @@ describe('DocumentAttributesComponent', () => {
expect(component.activeManagementList).toBeNull() expect(component.activeManagementList).toBeNull()
component.activeNavID.set(1) component.activeNavID.set(1)
const managementList = Object.create(ManagementListComponent.prototype)
component.activeOutlet = {
componentInstance: managementList,
} as any
expect(component.activeSection.kind).toBe( expect(component.activeSection.kind).toBe(
DocumentAttributesSectionKind.ManagementList DocumentAttributesSectionKind.ManagementList
) )
expect(component.activeManagementList).toBe(managementList) expect(component.activeManagementList).toBeDefined()
})
it('should use the current component instance when the outlet is reused', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
component.activeNavID.set(1)
const firstManagementList = Object.create(ManagementListComponent.prototype)
const secondManagementList = Object.create(
ManagementListComponent.prototype
)
component.activeOutlet = {
componentInstance: firstManagementList,
} as any
expect(component.activeManagementList).toBe(firstManagementList)
component.activeOutlet.componentInstance = secondManagementList
expect(component.activeManagementList).toBe(secondManagementList)
}) })
it('should return activeCustomFields correctly', () => { it('should return activeCustomFields correctly', () => {
@@ -132,8 +132,11 @@ export class DocumentAttributesComponent implements OnInit, OnDestroy {
] ]
@ViewChild('activeOutlet', { read: NgComponentOutlet }) @ViewChild('activeOutlet', { read: NgComponentOutlet })
activeOutlet: NgComponentOutlet set activeOutlet(outlet: NgComponentOutlet | undefined) {
this.activeComponent.set(outlet?.componentInstance ?? null)
}
readonly activeComponent = signal<unknown>(null)
readonly activeNavID = signal<number>(null) readonly activeNavID = signal<number>(null)
get visibleSections(): DocumentAttributesSection[] { get visibleSections(): DocumentAttributesSection[] {
@@ -158,14 +161,14 @@ export class DocumentAttributesComponent implements OnInit, OnDestroy {
this.activeSection?.kind !== DocumentAttributesSectionKind.ManagementList this.activeSection?.kind !== DocumentAttributesSectionKind.ManagementList
) )
return null return null
const instance = this.activeOutlet?.componentInstance const instance = this.activeComponent()
return instance instanceof ManagementListComponent ? instance : null return instance instanceof ManagementListComponent ? instance : null
} }
get activeCustomFields(): CustomFieldsComponent | null { get activeCustomFields(): CustomFieldsComponent | null {
if (this.activeSection?.kind !== DocumentAttributesSectionKind.CustomFields) if (this.activeSection?.kind !== DocumentAttributesSectionKind.CustomFields)
return null return null
const instance = this.activeOutlet?.componentInstance const instance = this.activeComponent()
return instance instanceof CustomFieldsComponent ? instance : null return instance instanceof CustomFieldsComponent ? instance : null
} }
@@ -1,18 +1,18 @@
<div class="row mb-3"> <div class="row mb-3">
<div class="col mb-2 mb-xl-0"> <div class="col mb-2 mb-xl-0">
<div class="form-inline d-flex align-items-center"> <div class="form-inline d-flex align-items-center">
<label class="text-muted me-2 mb-0" for="managementNameFilter" i18n>Filter by:</label> <label class="text-muted me-2 mb-0" i18n>Filter by:</label>
<input id="managementNameFilter" class="form-control form-control-sm flex-fill w-auto" type="text" autofocus [(ngModel)]="nameFilter" (keyup)="onNameFilterKeyUp($event)" placeholder="Name" i18n-placeholder> <input class="form-control form-control-sm flex-fill w-auto" type="text" autofocus [(ngModel)]="nameFilter" (keyup)="onNameFilterKeyUp($event)" placeholder="Name" i18n-placeholder>
</div> </div>
</div> </div>
<div class="col-auto mb-2 mb-xl-0"> <div class="col-auto mb-2 mb-xl-0">
<div class="form-inline d-flex align-items-center"> <div class="form-inline d-flex align-items-center">
<div class="input-group input-group-sm w-auto d-none d-md-flex"> <div class="input-group input-group-sm w-auto d-none d-md-flex">
<label class="input-group-text border-0" for="managementPageSize" i18n>Show:</label> <span class="input-group-text border-0" i18n>Show:</span>
</div> </div>
<div class="input-group input-group-sm w-auto me-3"> <div class="input-group input-group-sm w-auto me-3">
<select id="managementPageSize" class="form-select form-select-sm small" [(ngModel)]="pageSize"> <select class="form-select form-select-sm small" [(ngModel)]="pageSize">
<option [ngValue]="25">25</option> <option [ngValue]="25">25</option>
<option [ngValue]="50">50</option> <option [ngValue]="50">50</option>
<option [ngValue]="100">100</option> <option [ngValue]="100">100</option>
@@ -83,12 +83,12 @@ describe('TagListComponent', () => {
] ]
component['_nameFilter'] = null // Simulate empty name filter component['_nameFilter'] = null // Simulate empty name filter
const filtered = component.filterData(tags as any) const filtered = component.filterData(tags as any)
expect(filtered).toHaveLength(2) expect(filtered.length).toBe(2)
expect(filtered.find((t) => t.id === 2)).toBeUndefined() expect(filtered.find((t) => t.id === 2)).toBeUndefined()
component['_nameFilter'] = 'Tag2' // Simulate non-empty name filter component['_nameFilter'] = 'Tag2' // Simulate non-empty name filter
const filteredWithName = component.filterData(tags as any) const filteredWithName = component.filterData(tags as any)
expect(filteredWithName).toHaveLength(2) expect(filteredWithName.length).toBe(2)
expect(filteredWithName.find((t) => t.id === 2)).toBeUndefined() expect(filteredWithName.find((t) => t.id === 2)).toBeUndefined()
expect( expect(
filteredWithName filteredWithName
@@ -26,7 +26,7 @@
<th scope="col" style="width: 40px;"> <th scope="col" style="width: 40px;">
<div class="form-check m-0 ms-2 me-n2"> <div class="form-check m-0 ms-2 me-n2">
<input type="checkbox" class="form-check-input" id="all-objects" [ngModel]="toggleAllEnabled()" (ngModelChange)="toggleAllEnabled.set($event)" [disabled]="processedMails().length === 0" (click)="toggleAll($event); $event.stopPropagation();"> <input type="checkbox" class="form-check-input" id="all-objects" [ngModel]="toggleAllEnabled()" (ngModelChange)="toggleAllEnabled.set($event)" [disabled]="processedMails().length === 0" (click)="toggleAll($event); $event.stopPropagation();">
<label class="form-check-label" for="all-objects"><span class="visually-hidden" i18n>Select all processed email messages</span></label> <label class="form-check-label" for="all-objects"></label>
</div> </div>
</th> </th>
<th scope="col" i18n>Subject</th> <th scope="col" i18n>Subject</th>
@@ -5,6 +5,6 @@
pre { pre {
white-space: pre-wrap; white-space: pre-wrap;
overflow-wrap: break-word; word-break: break-word;
} }
} }

Some files were not shown because too many files have changed in this diff Show More